chore: merge master account theme updates
This commit is contained in:
commit
26af73ff50
87 files changed
+2545
-207
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md
|
||||
2026-09-14-deepseek-account-login.md: 804e5a2c180a866dc096cacef75315af1172efd3
|
||||
2026-09-14-deepseek-account-login.zh.md: 6e8344b0281c113be89f2dac68fa8f7d2c08830a
|
||||
2026-09-14-deepseek-account-login.md: 165cd2d0a2fbe3a4a71d3ce31de69c5005c639b2
|
||||
2026-09-14-deepseek-account-login.zh.md: 5e09c0a17cc933b46aa6498c70d86fdefd26d21c
|
||||
@@ -36,7 +36,7 @@ The account plugin supplies choice, waiting, failure and timeout dialogs through
|
||||
|
||||
Platform embedding transfers a grant from Host to Electron over private Node IPC and then once to the trusted Platform main frame through a sandboxed preload. Preload performs one synchronous IPC before page scripts execute; the main process only validates the sender and returns prepared memory. Platform reads the token synchronously thereafter without a readiness API. A failed initialization retains embedded mode and a throwing getter. Account UI projections remain credential-free. The Platform document can read this credential, so its script security is part of account protection; context isolation protects native capabilities, not a token intentionally returned to the document. Replacing or removing the grant destroys the document and its temporary session.
|
||||
|
||||
Support questionnaires receive only the Platform UID and available environment metadata through explicit prefill fields. Account tokens and masked contact details stay out of questionnaire URLs. The account provider projects the current-user UID for this purpose; the UI uses the latest account snapshot so sign-out cannot reuse a previous UID.
|
||||
Support questionnaires receive only available environment metadata through explicit prefill fields. Account UIDs, tokens and masked contact details stay out of questionnaire URLs.
|
||||
|
||||
Private proxy development can explicitly map authorization and completion URLs to platformOrigin with rewriteBrowserOrigin. Both pages retain their fixed paths and full query strings; allowing a remote origin without mapping it would send the browser outside the configured environment. Shipped configuration requires same-origin URLs.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ API Key 与账号记录独立保存。账号 token 没有主动刷新流程;
|
||||
|
||||
Platform 内嵌通过私有 Node IPC 将授权从 Host 传给 Electron,再经沙箱 preload 一次性传给受信任的 Platform 主 frame。preload 在页面脚本执行前执行一次同步 IPC;主进程仅校验调用来源并返回已准备的内存数据。Platform 此后同步读取 token,无需就绪接口。初始化失败时保留内嵌模式,由 getter 抛错。账号 UI 投影仍不包含凭证。Platform 文档能够读取此凭证,因此其脚本安全也是账号保护的一部分;上下文隔离保护原生能力,不能隐藏有意返回给文档的 token。替换或移除授权会销毁文档及其临时会话。
|
||||
|
||||
支持问卷仅通过显式预填字段接收 Platform UID 和已有的环境信息,URL 不包含账号 token 或脱敏联系方式。账号提供者为此投影 current-user 的 UID;UI 使用最新账号快照,避免退出登录后复用旧 UID。
|
||||
支持问卷仅通过显式预填字段接收已有的环境信息,URL 不包含账号 UID、token 或脱敏联系方式。
|
||||
|
||||
私有代理开发可通过 rewriteBrowserOrigin 显式将授权页和完成页映射到 platformOrigin。两者保留固定路径和完整查询字符串;仅放宽来源而不做映射会让浏览器离开配置的环境。发布配置要求浏览器地址同源。
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-21-persistent-pwsh-keeps-controlled-prompt.md
|
||||
2026-09-21-persistent-pwsh-keeps-controlled-prompt.md: 3fd42b77d4536215f34c32643526dbb03d21674f
|
||||
2026-09-21-persistent-pwsh-keeps-controlled-prompt.zh.md: 88d4e2deef26af4ad92ef4a69d8ae17403027f70
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Persistent pwsh keeps the backend's controlled prompt
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-21-persistent-pwsh-keeps-controlled-prompt.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-tool-pwsh-persistent` initialized its shell with a `prompt` function of its own (`'__DSH_PERSISTENT_PWSH_PROMPT__ '`), overwriting the `prompt` that `dsh-terminal-bash` installs in its pwsh startup sequence. The backend's prompt readiness requires the printable tail after the OSC `133;D` marker to exactly equal the controlled `dsh> ` prompt ([design](../feature/2026-07-16-persistent-pty-sessions.md)), so after initialization no send could settle through it and every send paid the silence tier plus handoff grace. Measured on Windows through the real Loader composition with production defaults: 8493 ms for the first call (spawn, initialization, and command) and 3722/3832/3759 ms for the next three, against 1340/255/251/241 ms with the controlled prompt intact. The package tests masked it by configuring `idleSilenceMs: 300`.
|
||||
|
||||
The [persistent bash tool fixed the identical defect](../../archived/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.md) by giving up its own prompt override; the pwsh twin kept it. The override served the same two consumers there: a viewport-suffix fallback detecting "shell at a prompt without the end marker", and cosmetic stripping of prompt text from partial output.
|
||||
|
||||
## Decision
|
||||
|
||||
The tool no longer installs a prompt. `dsh-terminal-bash` owns the pwsh `prompt` function, installs it during `spawn`, and returns the session only after that startup reached `stdin_read` readiness, so the tool's initialization send had nothing left to establish and is deleted together with its `PWSH_PROMPT_SETUP` command.
|
||||
|
||||
The viewport-suffix fallback is replaced with the seam's own signal: a send that settles as `stdin_read` without the end marker in scrollback returns the captured partial output. The private prompt constant and its stripping are deleted; partial output may now end with the backend's own prompt text, which the tool cannot and should not know. `stripPrompt` collapses to `trimTrailingNewline`, matching `dsh-tool-bash-persistent`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Align the tool's prompt text with the backend's `dsh> `.** Rejected: it keeps a second installation of one protocol constant and a second owner of it, and the send is redundant because the backend had already installed the identical prompt before `spawn` resolved.
|
||||
|
||||
**Import the controlled prompt into the tool.** Rejected: the prompt is one provider's protocol constant; a Consumer matching it would couple the tool to `dsh-terminal-bash` specifically, and any other mounted pwsh-dialect backend would break it again.
|
||||
|
||||
**Lower `handoffGraceMs`/`idleSilenceMs` instead.** Rejected: no silence value fixes a dead fast path; it rebalances how much every call overpays, and the silence tier is also what absorbs a genuinely broken prompt.
|
||||
|
||||
**Re-assert the pwsh prompt per prompt, as `PROMPT_COMMAND` does for bash.** Rejected: PowerShell has no per-prompt hook outside the `prompt` function itself, so a redefinition cannot be healed the way bash's variable is; the residue stays a documented limitation.
|
||||
|
||||
## Consequences
|
||||
|
||||
Windows, production defaults, real Loader composition: tool calls drop from 8493/3722/3832/3759 ms to 1340/255/251/241 ms (spawn + initialization + first command, then three warm commands).
|
||||
|
||||
The `stdin_read` fallback is behavior, not only cosmetics: where a provider proves a foreground stdin wait, an interactive child returns captured partial output instead of spinning to the command deadline, and elsewhere the call still runs to `timeoutMs`. That early return keeps the session, so a child still reading stdin can consume the next call's command until that call reaches `timeoutMs` and resets the shell; `dsh-tool-bash-persistent` behaves the same way, and distinguishing the shell's own prompt readiness from a child's stdin wait belongs to the seam rather than to either tool. Complete marker-delimited output is unchanged.
|
||||
|
||||
The loader-composition suite records every send's `waitReason` and requires at least six `stdin_read` settlements and no `inferred_idle`: the regression is reported by the settle reason itself, not by how long silence takes. Reinstating the removed prompt override fails that assertion after 30 s with `expected 0 to be greater than or equal to 6`, while the case's output assertions still pass. The stub suite drops the initialization-handshake modes and pins the prompt text retained in partial output instead of its absence.
|
||||
|
||||
A model command that redefines `prompt` still removes the readiness marker and degrades later sends to the silence tier: the backend installs its prompt once at startup and cannot re-assert it.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Agent Note: 持久 pwsh 保留后端的受控提示符
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-21-persistent-pwsh-keeps-controlled-prompt.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-tool-pwsh-persistent` 用自己安装的 `prompt` 函数(`'__DSH_PERSISTENT_PWSH_PROMPT__ '`)初始化其 shell,覆盖了 `dsh-terminal-bash` 在 pwsh 启动序列中安装的 `prompt`。后端的提示符就绪检测要求 OSC `133;D` 标记之后的可打印尾部与受控提示符 `dsh> ` 完全相等([设计](../feature/2026-07-16-persistent-pty-sessions.zh.md)),因此初始化之后任何 send 都无法经由该路径结算,每次 send 都要支付静默层加交接宽限。在 Windows 上通过真实 Loader 组合以生产默认值实测:首次调用(spawn、初始化与命令)8493 毫秒,随后三次为 3722/3832/3759 毫秒;受控提示符完好时为 1340/255/251/241 毫秒。包测试把 `idleSilenceMs` 配成 300,掩盖了该问题。
|
||||
|
||||
[持久 bash 工具已修复同一缺陷](../../archived/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.md),方式是放弃自己的提示符覆盖;pwsh 孪生工具保留了它。该覆盖在两个工具中服务同样的两个消费点:用视口后缀检测「shell 已回到提示符但没有结束标记」的回退判定,以及从部分输出中剥离提示符文本的美化。
|
||||
|
||||
## Decision
|
||||
|
||||
工具不再安装提示符。pwsh 的 `prompt` 函数由 `dsh-terminal-bash` 拥有,在 `spawn` 期间安装,且只在该启动流程达到 `stdin_read` 就绪后才返回会话,因此工具的初始化 send 已无可确立之事,与其 `PWSH_PROMPT_SETUP` 命令一并删除。
|
||||
|
||||
视口后缀回退替换为 seam 自己的信号:一次以 `stdin_read` 结算而 scrollback 中没有结束标记的 send,返回已捕获的部分输出。私有提示符常量及其剥离逻辑删除;部分输出现在可能以后端自己的提示符文本结尾,工具无法也不应知道该文本。`stripPrompt` 收敛为 `trimTrailingNewline`,与 `dsh-tool-bash-persistent` 一致。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**把工具的提示符文本对齐到后端的 `dsh> `。** 被拒绝:这保留了同一个协议常量的第二份安装与第二个拥有者,而且该 send 是多余的——后端在 `spawn` 返回前就已安装相同的提示符。
|
||||
|
||||
**把受控提示符导入工具。** 被拒绝:提示符是单个提供方的协议常量;Consumer 匹配它就把工具与 `dsh-terminal-bash` 具体耦合,换任何其他 pwsh 方言后端都会再次损坏。
|
||||
|
||||
**改为调小 `handoffGraceMs`/`idleSilenceMs`。** 被拒绝:任何静默值都修不好已死的快速路径,只是重新分配每次调用多付多少;而静默层同时也是吸收真正损坏提示符的那一层。
|
||||
|
||||
**像 bash 的 `PROMPT_COMMAND` 那样在每个提示符处重新设定 pwsh 提示符。** 被拒绝:除 `prompt` 函数本身之外,PowerShell 没有每提示符钩子,因此重定义无法像 bash 的变量那样被治好;该残余已记入已知限制。
|
||||
|
||||
## Consequences
|
||||
|
||||
Windows、生产默认值、真实 Loader 组合:工具调用从 8493/3722/3832/3759 毫秒降至 1340/255/251/241 毫秒(spawn + 初始化 + 首条命令,随后三条热命令)。
|
||||
|
||||
`stdin_read` 回退是行为而不只是美化:在提供方能证明前台 stdin 等待之处,交互式子进程会返回已捕获的部分输出,而不是空转到命令期限;其他情况下调用仍会运行到 `timeoutMs`。该提前返回保留会话,因此仍在读取 stdin 的子进程可能吞掉下一次调用的命令,直到那次调用到达 `timeoutMs` 并重置 shell;`dsh-tool-bash-persistent` 行为相同,而区分「shell 自身的提示符就绪」与「子进程的 stdin 等待」属于 seam,而不是任一工具。由标记界定的完整输出保持不变。
|
||||
|
||||
loader 组合套件记录每次 send 的 `waitReason`,要求至少六次 `stdin_read` 且没有 `inferred_idle`:回归由结算原因本身报告,而不是靠静默层耗时。把删掉的提示符覆盖加回去,该断言会在 30 秒后以 `expected 0 to be greater than or equal to 6` 失败,而同一用例的输出断言仍然通过。stub 套件删除了初始化握手模式,并改为锚定部分输出中保留的提示符文本,而不是它的缺席。
|
||||
|
||||
重定义 `prompt` 的模型命令仍会移除就绪标记,并把后续 send 降级到静默层:后端只在启动时安装一次提示符,无法重新设定。
|
||||
@@ -143,6 +143,7 @@ pnpm applies local patches to the following packages at install time, so shipped
|
||||
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `@yao-pkg/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
|
||||
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
|
||||
|
||||
## Official Claude Code platform payloads
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
README.md: 2a7cc8a35b5774fa3cf0c77ec95b9f16df4c24b7
|
||||
README.zh.md: 4f00d7f8240ab8d10679805679f4042b08ef4c59
|
||||
README.md: cb10794ae6e9c5cbdc9e0f58acdefe8a252e35e1
|
||||
README.zh.md: 778fe9c6bc8df8a727d2a03a818a44f6fbb2e0f9
|
||||
@@ -407,7 +407,7 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i
|
||||
- Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog.
|
||||
- The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate.
|
||||
|
||||
Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available.
|
||||
Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available.
|
||||
|
||||
An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry.
|
||||
|
||||
|
||||
@@ -407,7 +407,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
|
||||
- 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。
|
||||
- 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。
|
||||
|
||||
登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。
|
||||
登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。
|
||||
|
||||
登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。
|
||||
|
||||
|
||||
@@ -127,6 +127,20 @@ function chromeFallbackFill(): string {
|
||||
return nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb'
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the effective Desktop palette to a Platform authorization URL so the
|
||||
* login page opens in the application's theme. `system` resolves through
|
||||
* `nativeTheme.shouldUseDarkColors`, which follows the theme source the
|
||||
* application preload publishes.
|
||||
* @param authorizeUrl - validated Platform authorization URL.
|
||||
* @returns the authorization URL carrying `theme=light` or `theme=dark`.
|
||||
*/
|
||||
function platformLoginUrl(authorizeUrl: string): string {
|
||||
const url = new URL(authorizeUrl)
|
||||
url.searchParams.set('theme', nativeTheme.shouldUseDarkColors ? 'dark' : 'light')
|
||||
return url.href
|
||||
}
|
||||
|
||||
function createWindow(preload: string, show = false, primary = false): BrowserWindow {
|
||||
const window = new BrowserWindow({
|
||||
width: 1280,
|
||||
@@ -340,7 +354,7 @@ async function main(): Promise<void> {
|
||||
const attempt = state.attempt
|
||||
if (attempt?.phase === 'waiting-browser' && attempt.authorizeUrl !== undefined && openedAttempt !== attempt.id) {
|
||||
openedAttempt = attempt.id
|
||||
void shell.openExternal(attempt.authorizeUrl).catch(() => undefined)
|
||||
void shell.openExternal(platformLoginUrl(attempt.authorizeUrl)).catch(() => undefined)
|
||||
}
|
||||
if ((attempt?.phase === 'failed' || attempt?.phase === 'expired') && returnedAttempt !== attempt.id) {
|
||||
returnedAttempt = attempt.id
|
||||
@@ -904,7 +918,7 @@ async function main(): Promise<void> {
|
||||
if (state?.attempt?.id !== id || state.attempt.phase !== 'waiting-browser' || state.attempt.authorizeUrl === undefined) {
|
||||
throw new Error('desktop welcome: login link is unavailable')
|
||||
}
|
||||
await clipboard.writeText(state.attempt.authorizeUrl)
|
||||
await clipboard.writeText(platformLoginUrl(state.attempt.authorizeUrl))
|
||||
},
|
||||
saveApiKey: async (apiKey) => {
|
||||
if (backend.host === undefined || welcomeBackend === undefined) return { ok: false }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Mirrors the Web UI's theme source into the macOS native theme so window vibrancy follows the app palette. */
|
||||
/** Mirrors the Web UI's theme source into Electron's native theme so native chrome and Platform login pages follow the app palette. */
|
||||
|
||||
import { ipcRenderer } from 'electron'
|
||||
import { DESKTOP_IPC } from './ipc.ts'
|
||||
@@ -7,14 +7,15 @@ import { DESKTOP_IPC } from './ipc.ts'
|
||||
const THEME_SOURCE_ATTRIBUTE = 'data-ds-theme-source'
|
||||
|
||||
/**
|
||||
* On macOS, watches `html[data-ds-theme-source]` and forwards each value to
|
||||
* the main process, which sets `nativeTheme.themeSource` — the sidebar
|
||||
* vibrancy material then follows the app's theme preference instead of the
|
||||
* OS appearance, while `system` keeps following the OS. Other platforms
|
||||
* never send.
|
||||
* Watches `html[data-ds-theme-source]` and forwards each value to the main
|
||||
* process, which sets `nativeTheme.themeSource`. Native chrome and renderer
|
||||
* `prefers-color-scheme` queries on every platform then follow the app's
|
||||
* theme preference instead of the OS appearance while `system` keeps
|
||||
* following the OS; the macOS sidebar vibrancy material is one such consumer.
|
||||
* The main process reads the same value back as `shouldUseDarkColors` when a
|
||||
* Platform login link needs the resolved palette.
|
||||
*/
|
||||
export function syncNativeTheme(): void {
|
||||
if (process.platform !== 'darwin') return
|
||||
let sent: string | undefined
|
||||
const send = (): void => {
|
||||
const value = document.documentElement.getAttribute(THEME_SOURCE_ATTRIBUTE)
|
||||
@@ -28,6 +29,6 @@ export function syncNativeTheme(): void {
|
||||
}
|
||||
// The preload runs before the document root exists; the theme bootstrap
|
||||
// script writes the attribute before DOMContentLoaded.
|
||||
if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', observe)
|
||||
if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', observe, { once: true })
|
||||
else observe()
|
||||
}
|
||||
@@ -165,7 +165,7 @@ const harness = await vi.hoisted(async () => {
|
||||
get publishUpdate() { return publishUpdate! },
|
||||
set publishUpdate(value: (state: DesktopUpdateState) => DesktopUpdateState) { publishUpdate = value },
|
||||
dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn() },
|
||||
openExternal: vi.fn(),
|
||||
openExternal: vi.fn(async () => {}),
|
||||
protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Response | Promise<Response>) => void>(),
|
||||
applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
|
||||
disableAllPlugins: vi.fn(async () => {
|
||||
@@ -1634,3 +1634,21 @@ it.each(['failed', 'expired'] as const)('focuses DSH once when browser authoriza
|
||||
harness.publishAccount(state)
|
||||
expect(window.focus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([['light', false], ['dark', true]] as const)('opens Platform authorization in the effective %s palette', async (theme, shouldUseDarkColors) => {
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
harness.prepared.resolve()
|
||||
await harness.hostStarted.promise
|
||||
harness.hosts[0]!.ready.resolve()
|
||||
await Promise.resolve(invoke(DESKTOP_IPC.boot))
|
||||
harness.nativeTheme.shouldUseDarkColors = shouldUseDarkColors
|
||||
const state: AccountView = {
|
||||
status: 'signed-out', links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' },
|
||||
attempt: { id: 'test-theme-attempt' as NonNullable<AccountView['attempt']>['id'], phase: 'waiting-browser',
|
||||
authorizeUrl: 'https://platform.deepseek.com/dsh/authorize?state=state-1' },
|
||||
}
|
||||
harness.publishAccount(state)
|
||||
harness.publishAccount(state)
|
||||
expect(harness.openExternal).toHaveBeenCalledExactlyOnceWith(`https://platform.deepseek.com/dsh/authorize?state=state-1&theme=${theme}`)
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { DESKTOP_IPC } from '../src/ipc.ts'
|
||||
import { syncNativeTheme } from '../src/preload-theme.ts'
|
||||
|
||||
const send = vi.hoisted(() => vi.fn())
|
||||
vi.mock('electron', () => ({ ipcRenderer: { send } }))
|
||||
|
||||
/** Observers created by the module under test; jsdom keeps one document per file. */
|
||||
const observers = new Set<MutationObserver>()
|
||||
const NativeMutationObserver = MutationObserver
|
||||
|
||||
/** Records every observer so teardown detaches it from the shared document. */
|
||||
class TrackedMutationObserver extends NativeMutationObserver {
|
||||
constructor(callback: MutationCallback) {
|
||||
super(callback)
|
||||
observers.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const observer of observers) observer.disconnect()
|
||||
observers.clear()
|
||||
document.documentElement.removeAttribute('data-ds-theme-source')
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
send.mockClear()
|
||||
})
|
||||
|
||||
it.each(['darwin', 'win32'] as const)('mirrors the application theme source on %s', async (platform) => {
|
||||
vi.stubGlobal('MutationObserver', TrackedMutationObserver)
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
document.documentElement.setAttribute('data-ds-theme-source', 'dark')
|
||||
syncNativeTheme()
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.nativeThemeSet, 'dark')
|
||||
document.documentElement.setAttribute('data-ds-theme-source', 'system')
|
||||
await vi.waitFor(() => { expect(send).toHaveBeenLastCalledWith(DESKTOP_IPC.nativeThemeSet, 'system') })
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('defers observation until the document root exists', () => {
|
||||
vi.stubGlobal('MutationObserver', TrackedMutationObserver)
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading')
|
||||
syncNativeTheme()
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
document.documentElement.setAttribute('data-ds-theme-source', 'light')
|
||||
window.dispatchEvent(new Event('DOMContentLoaded'))
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.nativeThemeSet, 'light')
|
||||
})
|
||||
@@ -6,26 +6,73 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const preload = (name: string): string => fileURLToPath(new URL(`../lib/${name}.cjs`, import.meta.url))
|
||||
|
||||
/**
|
||||
* Browser globals a sandboxed Electron preload sees. A preload runs while the
|
||||
* document is still loading, so the theme preload defers its first read to
|
||||
* `DOMContentLoaded` and observes later `html[data-ds-theme-source]` changes.
|
||||
* @returns context globals plus the root attribute transitions the Web UI performs.
|
||||
*/
|
||||
function browserEnvironment() {
|
||||
const attributes = new Map<string, string>()
|
||||
const listeners = new Map<string, Array<() => void>>()
|
||||
const observers: Array<() => void> = []
|
||||
const document = {
|
||||
readyState: 'loading',
|
||||
documentElement: {
|
||||
dataset: {} as Record<string, string>,
|
||||
getAttribute: (name: string): string | null => attributes.get(name) ?? null,
|
||||
setAttribute: (name: string, value: string): void => { attributes.set(name, value) },
|
||||
},
|
||||
}
|
||||
const window = {
|
||||
addEventListener: (type: string, listener: () => void): void => {
|
||||
listeners.set(type, [...listeners.get(type) ?? [], listener])
|
||||
},
|
||||
}
|
||||
class SandboxMutationObserver {
|
||||
constructor(callback: () => void) { observers.push(callback) }
|
||||
observe(): void {}
|
||||
}
|
||||
return {
|
||||
globals: { document, window, MutationObserver: SandboxMutationObserver },
|
||||
/** Writes the theme root attribute and settles the document, as the theme bootstrap does before DOMContentLoaded. */
|
||||
loadTheme(value: string): void {
|
||||
attributes.set('data-ds-theme-source', value)
|
||||
for (const listener of listeners.get('DOMContentLoaded') ?? []) listener()
|
||||
},
|
||||
/** Applies a later theme preference change through the observer the preload registered. */
|
||||
changeTheme(value: string): void {
|
||||
attributes.set('data-ds-theme-source', value)
|
||||
for (const observer of observers) observer()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(preload('preload-app')))('built sandboxed Desktop preloads', () => {
|
||||
it.each(['preload-app', 'preload-welcome'])('%s loads without filesystem module access', (name) => {
|
||||
const exposed = new Map<string, Record<string, unknown>>()
|
||||
const invoke = vi.fn(() => Promise.resolve({ languages: ['en-US'], preference: 'zh' }))
|
||||
const send = vi.fn()
|
||||
const browser = browserEnvironment()
|
||||
const electron = {
|
||||
contextBridge: { exposeInMainWorld: (key: string, value: Record<string, unknown>) => { exposed.set(key, value) } },
|
||||
ipcRenderer: { invoke, send, on: vi.fn(), off: vi.fn() },
|
||||
}
|
||||
runInNewContext(readFileSync(preload(name), 'utf8'), {
|
||||
...browser.globals,
|
||||
require: (id: string) => {
|
||||
if (id !== 'electron') throw new Error(`sandbox cannot load ${id}`)
|
||||
return electron
|
||||
},
|
||||
process: { argv: ['electron', '--dsh-welcome-locale=en'] },
|
||||
location: new URL('dsh-app://app/'),
|
||||
document: { documentElement: { dataset: {} } },
|
||||
exports: {},
|
||||
})
|
||||
if (name === 'preload-app') {
|
||||
browser.loadTheme('dark')
|
||||
expect(send).toHaveBeenCalledWith('dsh-desktop:native-theme-set', 'dark')
|
||||
browser.changeTheme('light')
|
||||
expect(send).toHaveBeenCalledWith('dsh-desktop:native-theme-set', 'light')
|
||||
const bridge = exposed.get('__DSH_LOCALE__') as { read(): unknown; onChange(locale: string): void }
|
||||
bridge.read()
|
||||
expect(invoke).toHaveBeenCalledWith('dsh-desktop:locale-bootstrap')
|
||||
|
||||
@@ -33,6 +33,7 @@ const state = vi.hoisted(() => ({
|
||||
windowOptions: undefined as BrowserWindowConstructorOptions | undefined,
|
||||
menu: vi.fn(),
|
||||
operations: undefined as WelcomeOperations | undefined,
|
||||
nativeTheme: { themeSource: 'system', shouldUseDarkColors: false },
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -69,7 +70,7 @@ vi.mock('electron', () => ({
|
||||
async loadURL(url: string) { state.contents = this.webContents; await state.loadWorkspace(url); this.ready?.() }
|
||||
},
|
||||
net: { fetch: vi.fn() },
|
||||
nativeTheme: { themeSource: 'system' },
|
||||
nativeTheme: state.nativeTheme,
|
||||
session: { defaultSession: {
|
||||
setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: vi.fn() },
|
||||
} },
|
||||
@@ -184,11 +185,15 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho
|
||||
attempt: { id: attemptId, phase: 'waiting-browser', authorizeUrl: 'https://example.test/login' } }
|
||||
state.accountState.mockResolvedValue(account)
|
||||
await state.operations!.copySignInLink(attemptId)
|
||||
expect(state.copy).toHaveBeenCalledExactlyOnceWith('https://example.test/login')
|
||||
expect(state.copy).toHaveBeenCalledExactlyOnceWith('https://example.test/login?theme=light')
|
||||
state.nativeTheme.shouldUseDarkColors = true
|
||||
await state.operations!.copySignInLink(attemptId)
|
||||
expect(state.copy).toHaveBeenLastCalledWith('https://example.test/login?theme=dark')
|
||||
state.nativeTheme.shouldUseDarkColors = false
|
||||
await expect(state.operations!.copySignInLink('stale' as typeof attemptId)).rejects.toThrow('login link is unavailable')
|
||||
state.accountState.mockResolvedValue({ ...account, attempt: { id: attemptId, phase: 'expired' } })
|
||||
await expect(state.operations!.copySignInLink(attemptId)).rejects.toThrow('login link is unavailable')
|
||||
expect(state.copy).toHaveBeenCalledOnce()
|
||||
expect(state.copy).toHaveBeenCalledTimes(2)
|
||||
await state.operations!.skip()
|
||||
expect(state.loadWorkspace).not.toHaveBeenCalled()
|
||||
expect(state.showWorkspace).toHaveBeenCalledOnce()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/** Independent-writer XLSX previews through a replayed Session and the shipped browser Worker. */
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { excelOpeningCases, excelOpeningFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-opening-fixture.ts'
|
||||
import { excelOpcCases, excelOpcFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-opc-fixture.ts'
|
||||
import { excelXmlCases, excelXmlFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-xml-fixture.ts'
|
||||
import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
|
||||
|
||||
const snapshotDirectory = fileURLToPath(new URL('../../../snapshots/web/excel-opc', import.meta.url))
|
||||
const mode = webSnapshotMode()
|
||||
|
||||
describe.skipIf(mode === 'record')('web e2e: independent-writer Excel previews', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let consoleErrors: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
developerTools: false,
|
||||
replayFixture: fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)),
|
||||
compareReplaySession: false,
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
consoleErrors = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
try { await browser?.close() } finally { await scaffold?.close() }
|
||||
})
|
||||
|
||||
it('opens comments, Tables and equivalent OPC encodings without hiding cell data', async () => {
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill('Reply with the single word LIGHTHOUSE and stop.')
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||||
const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
|
||||
if (cwd === undefined) throw new Error('Excel preview Session has no workspace')
|
||||
for (const name of excelOpcCases) await writeFile(join(cwd, `${name}.xlsx`), await excelOpcFixture(name))
|
||||
for (const name of excelOpeningCases) await writeFile(join(cwd, `${name}.xlsx`), await excelOpeningFixture(name))
|
||||
for (const name of excelXmlCases) await writeFile(join(cwd, `${name}.xlsx`), await excelXmlFixture(name))
|
||||
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
await column.locator('[data-files-state="tree"]').waitFor()
|
||||
await column.locator('[data-files-reload]').click()
|
||||
const files = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
|
||||
const preview = column.locator('[data-textpreview-url]')
|
||||
const excel = preview.locator('[data-excel-preview]')
|
||||
const sheets = excel.locator('.luckysheet-sheets-item-name')
|
||||
const notice = excel.locator('[data-excel-unsupported-notice]')
|
||||
const rows = ['# Independent-writer Excel previews', '']
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
|
||||
for (const name of [...excelOpcCases, ...excelOpeningCases, ...excelXmlCases]) {
|
||||
await files.click()
|
||||
await column.locator('[data-files-entry="file"]').getByRole('button', { name: `${name}.xlsx`, exact: true }).click()
|
||||
await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}.xlsx`)).toBe(true)
|
||||
await sheets.getByText('数据', { exact: true }).waitFor()
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
|
||||
await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('Item')
|
||||
await page.keyboard.press('ControlOrMeta+C')
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toMatch(/^Item\r?\n$/)
|
||||
const rich = excelXmlCases.some(candidate => candidate === name)
|
||||
const opening = excelOpeningCases.some(candidate => candidate === name)
|
||||
expect(await notice.count()).toBe(name === 'combined' || rich || opening ? 1 : 0)
|
||||
if (name === 'combined' || rich || opening) {
|
||||
const features = rich ? 'charts, images, shapes, conditional formatting'
|
||||
: name === 'orphan-drawing' ? 'conditional formatting' : 'charts, conditional formatting'
|
||||
expect(await notice.innerText()).toBe(`This preview does not support ${features} in this workbook. Open it in a system application for the full experience.`)
|
||||
}
|
||||
rows.push(`- ${name}.xlsx: 数据 opened; Item copied`)
|
||||
}
|
||||
expect(await sheets.getByText('隐藏页', { exact: true }).isVisible()).toBe(false)
|
||||
const warning = await notice.innerText()
|
||||
expect(warning).toBe('This preview does not support charts, images, shapes, conditional formatting in this workbook. Open it in a system application for the full experience.')
|
||||
rows.push('- Relocated workbook: hidden sheet omitted', `- Relocated workbook notice: ${warning}`)
|
||||
expect(consoleErrors.pageErrors).toEqual([])
|
||||
expect(consoleErrors.warnings).toEqual([])
|
||||
await compareOrRefreshGolden(join(snapshotDirectory, 'excel.expected.md'), rows.join('\n'), mode)
|
||||
await assertFixtureInventory(snapshotDirectory, ['excel.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -53,6 +53,7 @@
|
||||
"tests/details-session-lifecycle.e2e.ts",
|
||||
"tests/sessionless-header.e2e.ts",
|
||||
"tests/document-preview.e2e.ts",
|
||||
"tests/excel-opc.e2e.ts",
|
||||
"tests/office-fixture.ts",
|
||||
"tests/sidebar-browser.e2e.ts",
|
||||
"tests/plugin-config.e2e.ts",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: b2a50d5e0c3b19bf69ec128272d318d7bd8d4b14
|
||||
config-catalog.zh.md: 96de23e9488a8f7c008aed0f851d89a02e9888b0
|
||||
config-catalog.md: 0a950288f7de0a283b1403621f705a3985b7176a
|
||||
config-catalog.zh.md: ec248077cc3623788588bdcb6a6899debf9cafb9
|
||||
@@ -3507,7 +3507,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:480`](../packages/shell/tool-pwsh-persistent/src/index.ts)
|
||||
Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:455`](../packages/shell/tool-pwsh-persistent/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-tool-ralph"></a>
|
||||
|
||||
|
||||
@@ -3509,7 +3509,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
来源: [`packages/shell/tool-pwsh-persistent/src/index.ts:480`](../packages/shell/tool-pwsh-persistent/src/index.ts)
|
||||
来源: [`packages/shell/tool-pwsh-persistent/src/index.ts:455`](../packages/shell/tool-pwsh-persistent/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-tool-ralph"></a>
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@ export const DARK_ATTRIBUTE = 'data-ds-dark-theme'
|
||||
|
||||
/**
|
||||
* Root attribute publishing the theme source (`light`, `dark`, or `system`)
|
||||
* for host shells that mirror it into native window chrome (the Electron
|
||||
* preload forwards it to `nativeTheme.themeSource` so macOS vibrancy follows
|
||||
* the app theme). `system` only when the preference is `system`; a fixed
|
||||
* preference (including registered theme ids) publishes its resolved scheme.
|
||||
* for host shells that mirror it into the native theme (the Electron preload
|
||||
* forwards it to `nativeTheme.themeSource`, so native chrome, renderer
|
||||
* `prefers-color-scheme` queries, and Platform login links follow the app
|
||||
* palette on every platform). `system` only when the preference is `system`;
|
||||
* a fixed preference (including registered theme ids) publishes its resolved
|
||||
* scheme.
|
||||
*/
|
||||
export const THEME_SOURCE_ATTRIBUTE = 'data-ds-theme-source'
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md
|
||||
README.md: 2800f077cf55bc1663d28523d0d104d320235f4e
|
||||
README.zh.md: ed0dbb07d64cf9fb2dd42395d27c15d8e8416230
|
||||
README.md: 2eb0f56daec2ad66c1407e397e30a872bda8c52e
|
||||
README.zh.md: f1c1a5b66327d9dee85536afb737efd2afa23acb
|
||||
@@ -32,7 +32,7 @@ Balances use Platform Web currency formatting: two decimal places and digit grou
|
||||
|
||||
Desktop usage and top-up actions open an isolated native Platform view below a 48px return bar. Back destroys the view and preserves the Account settings page. Loading failures retain the return action and show a centered localized error with Retry. Retry reloads the same destination; renderer commands never receive the account token.
|
||||
|
||||
Contact us opens the Feishu questionnaire in the system browser. It supplies the available Platform UID, build version, UI locale and physical screen resolution as prefill_* parameters, with hide_*=1 for every context field; tokens and contact details are excluded. Configure contactFormUrl on the ui-settings-account plugin to select another HTTPS form. contactSource defaults to empty until the questionnaire supports a Harness source option; OS and device fields remain unfilled, matching the Web implementation.
|
||||
The account menu's Feedback entry opens the Feishu questionnaire in the system browser. It supplies the available build version, UI locale and physical screen resolution as prefill_* parameters, with hide_*=1 for every context field; the account UID, tokens and contact details are excluded. Configure contactFormUrl on the ui-settings-account plugin to select another HTTPS form. contactSource defaults to empty until the questionnaire supports a Harness source option; OS and device fields remain unfilled, matching the Web implementation.
|
||||
|
||||
The sidebar account menu uses the shared Menu surface, backdrop blur, spacing, and row typography; feature styles only size its launcher.
|
||||
|
||||
@@ -50,7 +50,7 @@ The Account section appears first in Settings only while signed in; it is hidden
|
||||
|
||||
The [credentials subsystem](../../../docs/subsystems/credentials.md) owns storage APIs; the [architecture](../../../docs/architecture.md) explains application composition.
|
||||
|
||||
Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. The sidebar displays the profile name, falling back to the server-masked phone number or email when the name is absent; it stays blank while the profile is loading. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available and restores its label two seconds after each successful copy. Clipboard failures show Copy failed on the link for two seconds without interrupting sign-in. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. Sign-in failures render in that dialog; the launcher's inline failure line reports only a failed sign-out. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator.
|
||||
Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. The sidebar displays the profile name, falling back to the server-masked phone number or email when the name is absent; it stays blank while the profile is loading. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available and restores its label two seconds after each successful copy. Clipboard failures show Copy failed on the link for two seconds without interrupting sign-in. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. The dialog's Copy sign-in link and the Settings Open link append the resolved Desktop palette as `theme=light` or `theme=dark` from the live theme snapshot, so both routes reach the Platform page in the current application theme without changing the theme preference Platform has saved. Sign-in failures render in that dialog; the launcher's inline failure line reports only a failed sign-out. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator.
|
||||
|
||||
A terminal account-state stream failure appears in the sign-in dialog or Account settings. Plugin unload suppresses late failure reports.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Desktop 设置中的账号页面显示 DeepSeek 登录状态,并提供浏览
|
||||
|
||||
Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Platform 视图。返回操作销毁视图并保留 Account 设置页。加载失败时保留返回操作,居中显示本地化错误提示和“重试”按钮。重试重新加载同一目标页面;渲染进程命令不接收账号 token。
|
||||
|
||||
“联系我们”在系统浏览器中打开飞书问卷。链接通过 prefill_* 参数填写已有的 Platform UID、构建版本、界面语言和屏幕物理分辨率,并为所有上下文字段设置 hide_*=1;不传 token 或联系方式。可在 ui-settings-account 插件配置 contactFormUrl,切换到另一个 HTTPS 问卷。问卷支持 Harness 来源选项前,contactSource 默认为空;OS 和设备字段沿用 Web 实现,保持未填写。
|
||||
账号菜单的“意见反馈”入口在系统浏览器中打开飞书问卷。链接通过 prefill_* 参数填写已有的构建版本、界面语言和屏幕物理分辨率,并为所有上下文字段设置 hide_*=1;不传账号 UID、token 或联系方式。可在 ui-settings-account 插件配置 contactFormUrl,切换到另一个 HTTPS 问卷。问卷支持 Harness 来源选项前,contactSource 默认为空;OS 和设备字段沿用 Web 实现,保持未填写。
|
||||
|
||||
侧边栏账号菜单使用公共 Menu 的表面、背景模糊、间距和菜单项排版;功能样式仅设置入口布局。
|
||||
|
||||
@@ -50,7 +50,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl
|
||||
|
||||
[凭证子系统](../../../docs/subsystems/credentials.zh.md)定义存储接口;[架构](../../../docs/architecture.zh.md)说明应用组合。
|
||||
|
||||
账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。侧边栏优先显示昵称,缺少昵称时显示服务端返回的脱敏手机号或邮箱;资料加载期间保持留空。 弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮始终可用,并在每次复制成功两秒后恢复原文案。剪贴板失败时,链接原位显示“复制失败”两秒,不中断登录。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。登录失败在该弹窗内呈现;侧边栏入口的内联失败提示仅用于退出登录失败。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。
|
||||
账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。侧边栏优先显示昵称,缺少昵称时显示服务端返回的脱敏手机号或邮箱;资料加载期间保持留空。 弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮始终可用,并在每次复制成功两秒后恢复原文案。剪贴板失败时,链接原位显示“复制失败”两秒,不中断登录。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。弹窗的“复制登录链接”和设置页的“打开”链接会从当前主题快照读取 Desktop 生效配色,追加 `theme=light` 或 `theme=dark`,使两条路径都以当前应用主题打开 Platform 页面,且不改变 Platform 已保存的主题偏好。登录失败在该弹窗内呈现;侧边栏入口的内联失败提示仅用于退出登录失败。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。
|
||||
|
||||
账户状态流的终止错误会显示在登录对话框或账户设置中。插件卸载后不再报告延迟到达的错误。
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-theme"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -52,6 +53,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-deepseek-account": "workspace:^",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/big.js": "^6.2.2",
|
||||
|
||||
@@ -17,9 +17,10 @@ export type AccountMenuProps = PropsRuntime<'settings.launcher'> & PropsLocale<'
|
||||
* @returns account menu launcher.
|
||||
*/
|
||||
export function AccountMenu({
|
||||
wide, openSettings, openOnboarding, useAccount, signOut, hasRunningAccountTasks, contactUs, showLogin, start, cancel, t,
|
||||
wide, openSettings, openOnboarding, useAccount, useTheme, signOut, hasRunningAccountTasks, contactUs, showLogin, start, cancel, t,
|
||||
}: AccountMenuProps) {
|
||||
const account = useAccount(state => state)
|
||||
const colorScheme = useTheme(snapshot => snapshot.active.colorScheme)
|
||||
const signedIn = account.view?.status === 'credential-stored'
|
||||
const expired = account.view?.status === 'signed-out' && account.view.signOutReason === 'expired'
|
||||
const [expiryNotice, setExpiryNotice] = useState(false)
|
||||
@@ -59,7 +60,8 @@ export function AccountMenu({
|
||||
else if (id === 'signin') beginSignIn()
|
||||
else void requestSignOut()
|
||||
}} />
|
||||
{account.loginVisible && !account.onboarding && <SignInDialog account={account} start={start} cancel={cancel} t={t}
|
||||
{account.loginVisible && !account.onboarding && <SignInDialog account={account} colorScheme={colorScheme}
|
||||
start={start} cancel={cancel} t={t}
|
||||
close={() => { showLogin(false) }} useApiKey={() => { showLogin(false); openOnboarding('deepseek-official') }} />}
|
||||
{signedIn && signOutImpact !== undefined && <SignOutDialog running={signOutImpact} signOut={signOut}
|
||||
close={() => { setSignOutImpact(undefined) }} t={t} />}
|
||||
|
||||
@@ -6,12 +6,13 @@ import type { AccountSectionInjected } from './AccountSection.tsx'
|
||||
import { SignInDialog } from './SignInDialog.tsx'
|
||||
|
||||
/** @param props - onboarding completion, account state and actions. @returns account dialog while signed out. */
|
||||
export function AccountOnboarding({ complete, useApiKey, useAccount, setOnboarding, showLogin, start, cancel, t }:
|
||||
export function AccountOnboarding({ complete, useApiKey, useAccount, useTheme, setOnboarding, showLogin, start, cancel, t }:
|
||||
PropsRuntime<'settings.models.sign-in'> & PropsLocale<'settings.account'> & InjectFace<AccountSectionInjected>) {
|
||||
const account = useAccount(value => value)
|
||||
const colorScheme = useTheme(snapshot => snapshot.active.colorScheme)
|
||||
useEffect(() => { setOnboarding(true); return () => { setOnboarding(false) } }, [setOnboarding])
|
||||
useEffect(() => { if (account.view?.status === 'credential-stored') complete() }, [account.view?.status, complete])
|
||||
if (!account.view || account.view.status === 'credential-stored') return null
|
||||
return <SignInDialog account={account} start={start} cancel={cancel} t={t}
|
||||
return <SignInDialog account={account} colorScheme={colorScheme} start={start} cancel={cancel} t={t}
|
||||
close={() => { showLogin(false); complete() }} useApiKey={() => { showLogin(false); useApiKey() }} />
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import { useEffect, useState } from 'react'
|
||||
import { Button, IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type { PropsRuntime, PropsLocale, InjectFace, HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { PlatformOverlay, type PlatformBridge } from './PlatformOverlay.tsx'
|
||||
import { formatBalance } from './formatBalance.ts'
|
||||
import { AccountAvatar } from './AccountAvatar.tsx'
|
||||
import { authorizeUrlWithTheme } from './authorize-url.ts'
|
||||
import css from './AccountSection.module.css'
|
||||
|
||||
/** Safe account snapshot shared by the settings page and launcher. */
|
||||
@@ -30,11 +32,15 @@ export interface AccountSectionInjected {
|
||||
/** Desktop-only commands; absent in ordinary browsers. */
|
||||
platform?: PlatformBridge
|
||||
|
||||
/** Host-owned account stream observed through framework hooks. */
|
||||
hooks: { account: HostObservable<AccountSnapshot> }
|
||||
/** Account stream owned by the Host and theme snapshots published by the renderer, observed through framework hooks. */
|
||||
hooks: {
|
||||
account: HostObservable<AccountSnapshot>
|
||||
/** Palette the Platform login pages follow. */
|
||||
theme: HostObservable<ThemeSnapshot>
|
||||
}
|
||||
/** @returns after account details are refreshed; concurrent refreshes share a request. */
|
||||
refresh: () => Promise<void>
|
||||
/** Open the external support questionnaire with the current account and environment. */
|
||||
/** Open the external support questionnaire with the current build and browser environment. */
|
||||
contactUs: () => void
|
||||
/** Open or dismiss the login dialog. */
|
||||
showLogin: (visible: boolean) => void
|
||||
@@ -53,8 +59,9 @@ export interface AccountSectionInjected {
|
||||
export type AccountSectionProps =
|
||||
PropsRuntime<'settings.section'> & PropsLocale<'settings.account'> & InjectFace<AccountSectionInjected>
|
||||
/** @param props - localized actions and account subscription. @returns account settings UI. */
|
||||
export function AccountSection({ t, useAccount, start, cancel, refresh, platform }: AccountSectionProps) {
|
||||
export function AccountSection({ t, useAccount, useTheme, start, cancel, refresh, platform }: AccountSectionProps) {
|
||||
const { view: state, details, failed: streamFailed } = useAccount(value => value)
|
||||
const colorScheme = useTheme(snapshot => snapshot.active.colorScheme)
|
||||
const [platformPage, setPlatformPage] = useState<'usage' | 'top-up'>()
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -109,7 +116,8 @@ export function AccountSection({ t, useAccount, start, cancel, refresh, platform
|
||||
</a>}
|
||||
</div>
|
||||
{active && <div className={css.actions}>
|
||||
{attempt.authorizeUrl && <a className={css.linkButton} href={attempt.authorizeUrl} target="_blank" rel="noreferrer">
|
||||
{attempt.authorizeUrl && <a className={css.linkButton} href={authorizeUrlWithTheme(attempt.authorizeUrl, colorScheme)}
|
||||
target="_blank" rel="noreferrer">
|
||||
{t('open')}
|
||||
</a>}
|
||||
<Button variant="outline" className={css.button} disabled={busy || attempt.phase === 'committing'}
|
||||
|
||||
@@ -4,11 +4,14 @@ import { Button, IconCloseOutlineRegular, IconLoadingOutlineRegular, Modal } fro
|
||||
import type { AccountSnapshot } from './AccountSection.tsx'
|
||||
import type { SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type { AccountKey } from './locales.ts'
|
||||
import { authorizeUrlWithTheme } from './authorize-url.ts'
|
||||
import css from './SignInDialog.module.css'
|
||||
|
||||
/** @param props - safe account state, localized copy, and user actions. @returns login dialog. */
|
||||
export function SignInDialog({ account, start, cancel, close, useApiKey, t }: {
|
||||
export function SignInDialog({ account, colorScheme, start, cancel, close, useApiKey, t }: {
|
||||
account: AccountSnapshot
|
||||
/** Resolved scheme of the active Desktop theme; the copied link carries it. */
|
||||
colorScheme: 'light' | 'dark'
|
||||
start: () => Promise<void>
|
||||
cancel: (id: SignInAttemptId) => Promise<void>
|
||||
close: () => void
|
||||
@@ -49,7 +52,7 @@ export function SignInDialog({ account, start, cancel, close, useApiKey, t }: {
|
||||
}
|
||||
const copyLink = async (authorizeUrl: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(authorizeUrl)
|
||||
await navigator.clipboard.writeText(authorizeUrlWithTheme(authorizeUrl, colorScheme))
|
||||
setCopyResult({ messageKey: 'copiedLink' })
|
||||
} catch { setCopyResult({ messageKey: 'copyFailed' }) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Platform authorization links that carry the Desktop palette into the login page. */
|
||||
|
||||
/**
|
||||
* Add the resolved Desktop palette to a Platform authorization link without
|
||||
* dropping any parameter the Host already put on it.
|
||||
* @param authorizeUrl - authorization URL of the current account attempt.
|
||||
* @param colorScheme - resolved scheme of the active Desktop theme.
|
||||
* @returns the authorization URL carrying `theme=light` or `theme=dark`.
|
||||
*/
|
||||
export function authorizeUrlWithTheme(authorizeUrl: string, colorScheme: 'light' | 'dark'): string {
|
||||
const url = new URL(authorizeUrl)
|
||||
url.searchParams.set('theme', colorScheme)
|
||||
return url.href
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
/** Feishu questionnaire context follows the Platform Web ticket fields. */
|
||||
import type { Config } from '../contact-config.ts'
|
||||
import type { AccountProfile } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
|
||||
/**
|
||||
* Build an external questionnaire URL without authentication credentials.
|
||||
* @param config - questionnaire destination and supported source option.
|
||||
* @param context - currently available account and browser environment.
|
||||
* @param context - currently available build and browser environment.
|
||||
* @returns questionnaire URL with hidden, optionally prefilled context fields.
|
||||
*/
|
||||
export function contactUrl(config: Config, context: {
|
||||
uid: AccountProfile['id']
|
||||
version: string | undefined
|
||||
locale: string
|
||||
width: number
|
||||
@@ -21,7 +19,7 @@ export function contactUrl(config: Config, context: {
|
||||
const width = Math.round(context.width * ratio)
|
||||
const height = Math.round(context.height * ratio)
|
||||
const fields = {
|
||||
uid: context.uid, source: config.contactSource, app_version: context.version,
|
||||
source: config.contactSource, app_version: context.version,
|
||||
os_version: undefined, device_brand: undefined, device_model: undefined,
|
||||
app_locale: context.locale, screen_resolution: width > 0 && height > 0 ? `${width}x${height}` : undefined,
|
||||
}
|
||||
@@ -30,5 +28,7 @@ export function contactUrl(config: Config, context: {
|
||||
url.searchParams.delete(`prefill_${name}`)
|
||||
if (value) url.searchParams.set(`prefill_${name}`, value)
|
||||
}
|
||||
url.searchParams.delete('prefill_uid')
|
||||
url.searchParams.delete('hide_uid')
|
||||
return url.href
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { AccountView, AccountDetails } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type { PlatformBridge } from './PlatformOverlay.tsx'
|
||||
@@ -21,8 +22,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap { 'settings.account': AccountKey }
|
||||
}
|
||||
/** Services required by account settings. */
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session']
|
||||
/** @param ctx - browser plugin context. */
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme']
|
||||
/** Register account UI only in the Desktop renderer. @param ctx - client plugin context. */
|
||||
export function apply(ctx: Context): void {
|
||||
if (!('dshDesktop' in globalThis)) return
|
||||
ctx.effect(() => ctx.locale.register('settings.account', { en, zh }), 'account: dictionaries')
|
||||
@@ -91,9 +92,7 @@ export function apply(ctx: Context): void {
|
||||
...nativePlatform === undefined ? {} : { platform: nativePlatform },
|
||||
refresh,
|
||||
contactUs() {
|
||||
const profile = snapshot.details?.profile
|
||||
const url = contactUrl(config, {
|
||||
uid: profile?.status === 'ready' ? profile.value.id : null,
|
||||
version: process.env.DSH_CLIENT_VERSION,
|
||||
locale: ctx.locale.getSnapshot().active === 'zh' ? 'zh-CN' : 'en',
|
||||
width: window.screen.width, height: window.screen.height, pixelRatio: window.devicePixelRatio,
|
||||
@@ -102,10 +101,16 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
showLogin(visible) { publish({ ...snapshot, loginVisible: visible }) },
|
||||
setOnboarding(active) { publish({ ...snapshot, onboarding: active }) },
|
||||
hooks: { account: {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } },
|
||||
} },
|
||||
hooks: {
|
||||
account: {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } },
|
||||
},
|
||||
theme: {
|
||||
getSnapshot: () => ctx.theme.getTheme(),
|
||||
subscribe: listener => ctx.on('theme/change', listener),
|
||||
},
|
||||
},
|
||||
async start() {
|
||||
publish({ ...snapshot, loginVisible: true, loginFailed: false })
|
||||
const transport = (globalThis as typeof globalThis & {
|
||||
|
||||
@@ -9,7 +9,7 @@ export const en = {
|
||||
failureTitle: 'Could not sign in',
|
||||
platformFailed: 'Could not complete the operation. Try again.', platformRetry: 'Retry',
|
||||
loading: 'Loading…', backToHarness: 'Back to DeepSeek Harness',
|
||||
settings: 'Settings', contactUs: 'Contact us', menu: 'Account menu',
|
||||
settings: 'Settings', contactUs: 'Feedback', menu: 'Account menu',
|
||||
nav: 'Account', signedIn: 'Signed in to DeepSeek', signedOut: 'Not signed in',
|
||||
signIn: 'Sign in', signOut: 'Sign out',
|
||||
signOutUnknownDescription: 'Could not check running tasks. Signing out may interrupt tasks using this account. Sign out now?',
|
||||
@@ -38,7 +38,7 @@ export const zh: Record<AccountKey, string> = {
|
||||
failureTitle: '登录失败',
|
||||
platformFailed: '操作未完成,请重试', platformRetry: '重试',
|
||||
loading: '加载中…', backToHarness: '返回 DeepSeek Harness',
|
||||
settings: '设置', contactUs: '联系我们', menu: '账号菜单',
|
||||
settings: '设置', contactUs: '意见反馈', menu: '账号菜单',
|
||||
nav: '账号与余额', signedIn: '已登录 DeepSeek', signedOut: '尚未登录',
|
||||
signIn: '登录', signOut: '退出登录',
|
||||
signOutUnknownDescription: '暂时无法确认任务状态。退出登录可能会中断使用此账号的任务,是否继续?',
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface Config {
|
||||
}
|
||||
/** Validate public questionnaire options. */
|
||||
export const Config: z<Partial<Config>, Config> = z.object({
|
||||
contactFormUrl: z.string().pattern(/^https:\/\/[^/\s]+\//).default('https://trtgsjkv6r.feishu.cn/share/base/form/shrcnhcHE4A6lQaQ3v0raCXmBAg'),
|
||||
contactFormUrl: z.string().pattern(/^https:\/\/[^/\s]+\//).default('https://trtgsjkv6r.feishu.cn/share/base/form/shrcnlCoGElW7MQznGy9r3YYXcg'),
|
||||
contactSource: z.string().default(''),
|
||||
})
|
||||
/** Bootstrap key containing no account credentials. */
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen, act } from '@testing-library/react'
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type { PlatformBridge } from '../src/client/PlatformOverlay.tsx'
|
||||
import { AccountSection, type AccountSectionInjected, type AccountSnapshot } from '../src/client/AccountSection.tsx'
|
||||
import type {} from '../src/client/index.ts'
|
||||
@@ -10,22 +11,36 @@ import { en, zh, type AccountKey } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||
|
||||
function mount(state: Omit<AccountView, 'links'>, copy: typeof en | typeof zh = en, details?: Partial<AccountDetails>, platform?: PlatformBridge) {
|
||||
const operations: AccountSectionInjected = {
|
||||
/** One resolved theme snapshot per scheme; the slot's theme hook serves these in the application. */
|
||||
const themeOf = (colorScheme: 'light' | 'dark'): ThemeSnapshot => ({
|
||||
preference: colorScheme, fontSize: 14,
|
||||
active: { id: colorScheme, colorScheme, tokens: {} }, themes: [], revision: 0,
|
||||
})
|
||||
|
||||
function operationsOf(state: Omit<AccountView, 'links'>, details?: Partial<AccountDetails>, platform?: PlatformBridge): AccountSectionInjected {
|
||||
return {
|
||||
...platform === undefined ? {} : { platform },
|
||||
hooks: { account: {
|
||||
getSnapshot: () => ({ view: { ...state, links: { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' } }, details, failed: false }),
|
||||
subscribe: () => () => {},
|
||||
} },
|
||||
hooks: {
|
||||
account: {
|
||||
getSnapshot: () => ({ view: { ...state, links: { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' } }, details, failed: false }),
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
theme: { getSnapshot: () => themeOf('light'), subscribe: () => () => {} },
|
||||
},
|
||||
hasRunningAccountTasks: vi.fn(async () => false),
|
||||
contactUs: vi.fn(), showLogin: vi.fn(), setOnboarding: vi.fn(),
|
||||
refresh: vi.fn(() => Promise.resolve()),
|
||||
start: vi.fn(() => Promise.resolve()), cancel: vi.fn(() => Promise.resolve()), signOut: vi.fn(() => Promise.resolve()),
|
||||
}
|
||||
}
|
||||
|
||||
function mount(state: Omit<AccountView, 'links'>, copy: typeof en | typeof zh = en, details?: Partial<AccountDetails>, platform?: PlatformBridge) {
|
||||
const operations = operationsOf(state, details, platform)
|
||||
// AccountSection consumes no global hooks; the slot supplies them in the application.
|
||||
const globals = {} as GlobalStandardProps
|
||||
render(<AccountSection {...globals} {...operations}
|
||||
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
|
||||
useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
|
||||
close={() => {}} t={key => key in copy ? copy[key as AccountKey] : key} />)
|
||||
return operations
|
||||
}
|
||||
@@ -59,7 +74,8 @@ it.each([en, zh].flatMap(copy => ([false, true, 'unknown'] as const).map(running
|
||||
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
|
||||
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations} signOut={signOut}
|
||||
hasRunningAccountTasks={async () => { if (running === 'unknown') throw new Error('offline'); return running }}
|
||||
useAccount={selector => selector(operations.hooks.account.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings}
|
||||
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
|
||||
useTheme={selector => selector(operations.hooks.theme.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings}
|
||||
t={key => key in copy ? copy[key as AccountKey] : key} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: copy.menu }))
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([copy.settings, copy.contactUs, copy.signOut])
|
||||
@@ -91,14 +107,16 @@ it('reports a failed start in the login dialog, not as a sidebar alert', async (
|
||||
return Promise.reject(new Error('account start failed'))
|
||||
})
|
||||
const view = render(<AccountMenu {...({} as GlobalStandardProps)} {...operations} start={start}
|
||||
useAccount={selector => selector(snapshot)} wide openOnboarding={() => {}} openSettings={() => {}}
|
||||
useAccount={selector => selector(snapshot)} useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
|
||||
wide openOnboarding={() => {}} openSettings={() => {}}
|
||||
t={key => key in en ? en[key as AccountKey] : key} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.menu }))
|
||||
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: en.signIn })) })
|
||||
expect(start).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
view.rerender(<AccountMenu {...({} as GlobalStandardProps)} {...operations} start={start}
|
||||
useAccount={selector => selector(snapshot)} wide openOnboarding={() => {}} openSettings={() => {}}
|
||||
useAccount={selector => selector(snapshot)} useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
|
||||
wide openOnboarding={() => {}} openSettings={() => {}}
|
||||
t={key => key in en ? en[key as AccountKey] : key} />)
|
||||
expect(screen.getByRole('dialog').textContent).toContain(en.failed)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
@@ -113,7 +131,8 @@ it('keeps the confirmation dialog open after a failed sign-out', async () => {
|
||||
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
|
||||
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations}
|
||||
signOut={() => Promise.reject(new Error('account sign-out failed'))}
|
||||
useAccount={selector => selector(operations.hooks.account.getSnapshot())} wide
|
||||
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
|
||||
useTheme={selector => selector(operations.hooks.theme.getSnapshot())} wide
|
||||
openOnboarding={() => {}} openSettings={() => {}} t={key => key in en ? en[key as AccountKey] : key} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.menu }))
|
||||
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: en.signOut })) })
|
||||
@@ -259,6 +278,7 @@ it.each([
|
||||
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
|
||||
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations}
|
||||
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
|
||||
useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
|
||||
wide openSettings={() => {}} openOnboarding={() => {}} t={key => en[key as AccountKey]} />)
|
||||
expect(screen.getByRole('button', { name: en.menu }).textContent).toBe(expected)
|
||||
})
|
||||
@@ -270,6 +290,7 @@ it('shows the profile image in settings and the sidebar, with independent load-e
|
||||
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
|
||||
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations}
|
||||
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
|
||||
useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
|
||||
wide openSettings={() => {}} openOnboarding={() => {}} t={key => en[key as AccountKey]} />)
|
||||
const images = document.querySelectorAll('img')
|
||||
expect(images).toHaveLength(2)
|
||||
@@ -335,7 +356,8 @@ it.each([en, zh])('shows a localized toast when the account credential expires',
|
||||
cleanup()
|
||||
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
|
||||
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations}
|
||||
useAccount={selector => selector(operations.hooks.account.getSnapshot())} wide openOnboarding={() => {}} openSettings={() => {}}
|
||||
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
|
||||
useTheme={selector => selector(operations.hooks.theme.getSnapshot())} wide openOnboarding={() => {}} openSettings={() => {}}
|
||||
t={key => key in copy ? copy[key as AccountKey] : key} />)
|
||||
expect(screen.getByRole('alert').textContent).toContain(copy.sessionExpired)
|
||||
await expect(`${screen.getByRole('alert').textContent}\n`).toMatchFileSnapshot(`./expected/expired-${copy === en ? 'en' : 'zh'}.txt`)
|
||||
@@ -345,12 +367,26 @@ it.each([en, zh])('shows a localized toast when the account credential expires',
|
||||
|
||||
it.each(['initializing', 'waiting-browser', 'exchanging'] as const)('shows %s and lets the user cancel the active attempt', async (phase) => {
|
||||
const operations = mount({ status: 'signed-out', attempt: { id: 'attempt' as SignInAttemptId, phase, authorizeUrl: 'https://example.test/authorize' } })
|
||||
expect(screen.getByRole('link', { name: en.open }).getAttribute('href')).toBe('https://example.test/authorize')
|
||||
expect(screen.getByRole('link', { name: en.open }).getAttribute('href')).toBe('https://example.test/authorize?theme=light')
|
||||
expect(screen.getByRole('status').textContent).toBe(phase === 'initializing' ? en.initializing : phase === 'waiting-browser' ? en.waiting : en.completing)
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.cancel })) })
|
||||
expect(operations.cancel).toHaveBeenCalledExactlyOnceWith('attempt')
|
||||
})
|
||||
|
||||
it('opens the authorization link with the active Desktop palette and follows later switches', () => {
|
||||
const authorizeUrl = 'https://platform.deepseek.com/dsh/authorize?state=example'
|
||||
const operations = operationsOf({ status: 'signed-out', attempt: { id: 'attempt' as SignInAttemptId, phase: 'waiting-browser', authorizeUrl } })
|
||||
const element = (theme: ThemeSnapshot) => <AccountSection {...({} as GlobalStandardProps)} {...operations}
|
||||
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
|
||||
useTheme={selector => selector(theme)} close={() => {}} t={key => key in en ? en[key as AccountKey] : key} />
|
||||
const view = render(element(themeOf('dark')))
|
||||
expect(screen.getByRole('link', { name: en.open }).getAttribute('href'))
|
||||
.toBe('https://platform.deepseek.com/dsh/authorize?state=example&theme=dark')
|
||||
view.rerender(element(themeOf('light')))
|
||||
expect(screen.getByRole('link', { name: en.open }).getAttribute('href'))
|
||||
.toBe('https://platform.deepseek.com/dsh/authorize?state=example&theme=light')
|
||||
})
|
||||
|
||||
it.each(['failed', 'expired'] as const)('allows a new sign-in after the attempt is %s', (phase) => {
|
||||
mount({ status: 'signed-out', attempt: { id: 'attempt' as SignInAttemptId, phase } })
|
||||
expect(screen.getByRole('button', { name: en.signIn }).hasAttribute('disabled')).toBe(false)
|
||||
@@ -375,7 +411,8 @@ it('reports a rejected settings login and disables login while initial state is
|
||||
let snapshot: AccountSnapshot = { view: undefined, details: undefined, failed: true }
|
||||
const props = { ...({} as GlobalStandardProps), ...operations,
|
||||
start: vi.fn(async () => { throw new Error('unavailable') }),
|
||||
useAccount: <T,>(select: (value: AccountSnapshot) => T) => select(snapshot), close: () => {},
|
||||
useAccount: <T,>(select: (value: AccountSnapshot) => T) => select(snapshot),
|
||||
useTheme: <T,>(select: (value: ThemeSnapshot) => T) => select(operations.hooks.theme.getSnapshot()), close: () => {},
|
||||
t: (key: string) => en[key as AccountKey],
|
||||
}
|
||||
const view = render(<AccountSection {...props} />)
|
||||
@@ -394,7 +431,9 @@ it('dismisses a collapsed menu and hands its login dialog to the API-key onboard
|
||||
const openOnboarding = vi.fn()
|
||||
let snapshot = operations.hooks.account.getSnapshot()
|
||||
const props = { ...({} as GlobalStandardProps), ...operations, wide: false, openSettings: vi.fn(), openOnboarding,
|
||||
useAccount: <T,>(select: (value: AccountSnapshot) => T) => select(snapshot), t: (key: string) => en[key as AccountKey] }
|
||||
useAccount: <T,>(select: (value: AccountSnapshot) => T) => select(snapshot),
|
||||
useTheme: <T,>(select: (value: ThemeSnapshot) => T) => select(operations.hooks.theme.getSnapshot()),
|
||||
t: (key: string) => en[key as AccountKey] }
|
||||
const view = render(<AccountMenu {...props} />)
|
||||
expect(screen.getByRole('button', { name: en.menu }).textContent).toBe('')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.menu }))
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ok } from '@deepseek-ai/dsh-remote-mock'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
|
||||
import type { AccountDetails, AccountView, AccountUserId, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type { ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { AccountSectionInjected } from '../src/client/AccountSection.tsx'
|
||||
import { CONTACT_CONFIG_GLOBAL } from '../src/contact-config.ts'
|
||||
@@ -40,6 +41,16 @@ it('shares account actions across seats, publishes dialog ownership, and opens c
|
||||
const c = await start()
|
||||
const actions = operations(c)
|
||||
expect(c.ctx.slots.entries('settings.models.sign-in')[0]!.inject!()).toBe(actions)
|
||||
// The account UI follows the live theme service through the framework hook channel.
|
||||
const theme = c.ctx.get('theme') as ThemeRuntime
|
||||
const onTheme = vi.fn()
|
||||
const offTheme = actions.hooks.theme.subscribe(onTheme)
|
||||
expect(actions.hooks.theme.getSnapshot()).toBe(theme.getTheme())
|
||||
const probe = theme.register({ id: 'probe', colorScheme: 'dark', tokens: {} })
|
||||
expect(onTheme).toHaveBeenCalledOnce()
|
||||
probe()
|
||||
offTheme()
|
||||
expect(theme.getTheme().themes.map(candidate => candidate.id)).toEqual(['light', 'dark'])
|
||||
await actions.refresh()
|
||||
expect(c.mock.remote.account.getProfile).not.toHaveBeenCalled()
|
||||
const listener = vi.fn()
|
||||
@@ -52,7 +63,9 @@ it('shares account actions across seats, publishes dialog ownership, and opens c
|
||||
actions.showLogin(false)
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
actions.contactUs()
|
||||
expect(new URL(String(open.mock.calls.at(-1)![0])).searchParams.has('prefill_uid')).toBe(false)
|
||||
const signedOut = new URL(String(open.mock.calls.at(-1)![0]))
|
||||
expect(signedOut.searchParams.has('prefill_uid')).toBe(false)
|
||||
expect(signedOut.searchParams.has('hide_uid')).toBe(false)
|
||||
c.mock.remote.account.getProfile.mockResolvedValue(ok(profile))
|
||||
c.mock.streams.push('account/watch', stored)
|
||||
await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().details?.profile).toEqual(profile) })
|
||||
@@ -62,7 +75,8 @@ it('shares account actions across seats, publishes dialog ownership, and opens c
|
||||
vi.spyOn(c.ctx.locale, 'getSnapshot').mockReturnValue({ ...c.ctx.locale.getSnapshot(), active: 'zh' })
|
||||
actions.contactUs()
|
||||
const support = new URL(String(open.mock.calls.at(-1)![0]))
|
||||
expect(support.searchParams.get('prefill_uid')).toBe('account-user')
|
||||
expect(support.searchParams.has('prefill_uid')).toBe(false)
|
||||
expect(support.searchParams.has('hide_uid')).toBe(false)
|
||||
expect(support.searchParams.get('prefill_app_locale')).toBe('zh-CN')
|
||||
await c.unload(SELF)
|
||||
expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(0)
|
||||
|
||||
@@ -1,38 +1,42 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import { Config } from '../src/contact-config.ts'
|
||||
import { contactUrl } from '../src/client/contact-url.ts'
|
||||
|
||||
it('prefills available support context and hides all context fields', () => {
|
||||
const config = Config({ contactSource: 'app_harness' })
|
||||
const url = new URL(contactUrl(config, {
|
||||
uid: 'user&123' as AccountUserId, version: '1.2.3', locale: 'zh-CN',
|
||||
width: 1512, height: 982, pixelRatio: 2,
|
||||
version: '1.2.3', locale: 'zh-CN', width: 1512, height: 982, pixelRatio: 2,
|
||||
}))
|
||||
expect(url.origin).toBe('https://trtgsjkv6r.feishu.cn')
|
||||
expect(url.pathname).toBe('/share/base/form/shrcnlCoGElW7MQznGy9r3YYXcg')
|
||||
expect(Object.fromEntries(url.searchParams)).toEqual({
|
||||
hide_uid: '1', prefill_uid: 'user&123', hide_source: '1', prefill_source: 'app_harness',
|
||||
hide_app_version: '1', prefill_app_version: '1.2.3', hide_os_version: '1',
|
||||
hide_source: '1', prefill_source: 'app_harness', hide_app_version: '1', prefill_app_version: '1.2.3',
|
||||
hide_os_version: '1',
|
||||
hide_device_brand: '1', hide_device_model: '1', hide_app_locale: '1', prefill_app_locale: 'zh-CN',
|
||||
hide_screen_resolution: '1', prefill_screen_resolution: '3024x1964',
|
||||
})
|
||||
expect(url.searchParams.has('prefill_uid')).toBe(false)
|
||||
expect(url.searchParams.has('hide_uid')).toBe(false)
|
||||
})
|
||||
|
||||
it('opens a configured form without stale account context when signed out', () => {
|
||||
const url = new URL(contactUrl(Config({ contactFormUrl: 'https://example.test/form/?prefill_uid=old&prefill_source=old' }), {
|
||||
uid: null, version: undefined, locale: 'en', width: 0, height: 0, pixelRatio: 1,
|
||||
it('opens a configured form without stale context when unavailable', () => {
|
||||
const url = new URL(contactUrl(Config({ contactFormUrl: 'https://example.test/form/?prefill_uid=old&hide_uid=1&prefill_app_version=old&prefill_source=old' }), {
|
||||
version: undefined, locale: 'en', width: 0, height: 0, pixelRatio: 1,
|
||||
}))
|
||||
expect(url.origin).toBe('https://example.test')
|
||||
expect(url.searchParams.has('prefill_uid')).toBe(false)
|
||||
expect(url.searchParams.has('hide_uid')).toBe(false)
|
||||
expect(url.searchParams.has('prefill_app_version')).toBe(false)
|
||||
expect(url.searchParams.has('prefill_source')).toBe(false)
|
||||
expect(url.searchParams.has('prefill_screen_resolution')).toBe(false)
|
||||
expect(url.searchParams.get('hide_uid')).toBe('1')
|
||||
expect(url.searchParams.get('hide_source')).toBe('1')
|
||||
expect(url.searchParams.get('hide_app_version')).toBe('1')
|
||||
expect(() => Config({ contactFormUrl: 'javascript:alert(1)' })).toThrow()
|
||||
})
|
||||
|
||||
it.each([NaN, Infinity])('uses CSS pixel dimensions when device pixel ratio is %s', (pixelRatio) => {
|
||||
const url = new URL(contactUrl(Config({}), {
|
||||
uid: null, version: undefined, locale: 'en', width: 800, height: 600, pixelRatio,
|
||||
version: undefined, locale: 'en', width: 800, height: 600, pixelRatio,
|
||||
}))
|
||||
expect(url.searchParams.get('prefill_screen_resolution')).toBe('800x600')
|
||||
})
|
||||
@@ -1 +1 @@
|
||||
SettingsContact usSign out
|
||||
SettingsFeedbackSign out
|
||||
@@ -1 +1 @@
|
||||
设置联系我们退出登录
|
||||
设置意见反馈退出登录
|
||||
@@ -3,6 +3,7 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { AccountOnboarding } from '../src/client/AccountOnboarding.tsx'
|
||||
import type { AccountSnapshot } from '../src/client/AccountSection.tsx'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -10,6 +11,11 @@ import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const theme: ThemeSnapshot = {
|
||||
preference: 'light', fontSize: 14,
|
||||
active: { id: 'light', colorScheme: 'light', tokens: {} }, themes: [], revision: 0,
|
||||
}
|
||||
|
||||
it('claims onboarding while loading, offers API key fallback, and completes after login', () => {
|
||||
let account: AccountSnapshot = { view: undefined, details: undefined, failed: false }
|
||||
const unused = (): never => { throw new Error('AccountOnboarding does not consume global hooks') }
|
||||
@@ -22,6 +28,7 @@ it('claims onboarding while loading, offers API key fallback, and completes afte
|
||||
hasRunningAccountTasks: vi.fn(async () => false),
|
||||
complete: vi.fn(), useApiKey: vi.fn(), setOnboarding: vi.fn(), showLogin: vi.fn(),
|
||||
useAccount: <T,>(select: (snapshot: AccountSnapshot) => T) => select(account),
|
||||
useTheme: <T,>(select: (snapshot: ThemeSnapshot) => T) => select(theme),
|
||||
start: vi.fn(async () => {}), cancel: vi.fn(async () => {}), signOut: vi.fn(async () => {}),
|
||||
refresh: vi.fn(async () => {}), contactUs: vi.fn(), t: makeTranslate(en),
|
||||
}
|
||||
|
||||
@@ -8,13 +8,16 @@ import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
const id = 'login-attempt' as SignInAttemptId
|
||||
function mount(attempt: AccountView['attempt'], copy: typeof en | typeof zh = en) {
|
||||
function dialogProps(attempt: AccountView['attempt'], copy: typeof en | typeof zh = en, colorScheme: 'light' | 'dark' = 'dark') {
|
||||
const start = vi.fn(async () => {})
|
||||
const cancel = vi.fn(async () => {})
|
||||
const close = vi.fn()
|
||||
const useApiKey = vi.fn()
|
||||
const props = { start, cancel, close, useApiKey, t: (key: keyof typeof en) => copy[key],
|
||||
return { start, cancel, close, useApiKey, colorScheme, t: (key: keyof typeof en) => copy[key],
|
||||
account: { view: { status: 'signed-out' as const, attempt, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }, details: undefined, failed: false } }
|
||||
}
|
||||
function mount(attempt: AccountView['attempt'], copy: typeof en | typeof zh = en, colorScheme: 'light' | 'dark' = 'dark') {
|
||||
const props = dialogProps(attempt, copy, colorScheme)
|
||||
render(<SignInDialog {...props} />)
|
||||
return props
|
||||
}
|
||||
@@ -57,7 +60,7 @@ it.each([en, zh])('restores the copy label two seconds after the latest successf
|
||||
const authorizeUrl = 'https://platform.deepseek.com/dsh/authorize?state=example'
|
||||
mount({ id, phase: 'waiting-browser', authorizeUrl }, copy)
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.copyLink })) })
|
||||
expect(writeText).toHaveBeenLastCalledWith(authorizeUrl)
|
||||
expect(writeText).toHaveBeenLastCalledWith('https://platform.deepseek.com/dsh/authorize?state=example&theme=dark')
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.copiedLink })) })
|
||||
expect(writeText).toHaveBeenCalledTimes(2)
|
||||
@@ -74,6 +77,25 @@ it.each([en, zh])('restores the copy label two seconds after the latest successf
|
||||
}
|
||||
})
|
||||
|
||||
it('copies the authorization link with the palette of the current render', async () => {
|
||||
const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
|
||||
const writeText = vi.fn(async () => {})
|
||||
try {
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
const attempt = { id, phase: 'waiting-browser' as const, authorizeUrl: 'https://platform.deepseek.com/dsh/authorize?state=example' }
|
||||
const props = dialogProps(attempt, en, 'dark')
|
||||
const view = render(<SignInDialog {...props} />)
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.copyLink })) })
|
||||
expect(writeText).toHaveBeenLastCalledWith('https://platform.deepseek.com/dsh/authorize?state=example&theme=dark')
|
||||
view.rerender(<SignInDialog {...props} colorScheme="light" />)
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.copiedLink })) })
|
||||
expect(writeText).toHaveBeenLastCalledWith('https://platform.deepseek.com/dsh/authorize?state=example&theme=light')
|
||||
} finally {
|
||||
if (clipboard) Object.defineProperty(navigator, 'clipboard', clipboard)
|
||||
else Reflect.deleteProperty(navigator, 'clipboard')
|
||||
}
|
||||
})
|
||||
|
||||
it.each([en, zh])('shows a temporary copy failure without interrupting sign-in', async (copy) => {
|
||||
const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../ui-theme/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/deepseek-account"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md
|
||||
README.md: 554aae41691148602b6fe563d802d0215adf2035
|
||||
README.zh.md: f7503f901311649adf161b8dcad07e73267d83d2
|
||||
README.md: 224957fec45fd4d2849df677b9cada62d01d18cd
|
||||
README.zh.md: 18fe9e94617291d85fd3db7bf1c7bcc347f6ec73
|
||||
@@ -84,6 +84,8 @@ The lazy Excel chunk bundles FortuneSheet, ExcelJS for XLSX, SheetJS CE for XLS,
|
||||
|
||||
The read-only formula bar displays formulas and cell text literally. Copying preserves an HTML table with escaped cell contents, including saved formula results. The [FortuneSheet patch rationale](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md) explains the requirements for retaining these behaviors and worksheet selection when upgrading the dependency.
|
||||
|
||||
The pinned [ExcelJS patch](../../../patches/[email protected]) resolves the workbook, styles, shared strings, worksheets, comments, Tables, and VML through package relationships, including absolute and relative targets and ASCII case-equivalent part names, and recognizes SpreadsheetML and VML names by namespace URI. Strict OOXML SpreadsheetML and relationship URIs map to the same supported preview features; this is not full Strict conformance. XML parts accept UTF-8 and either byte order of UTF-16; CDATA contributes literal text. Drawing and conditional-format notices follow relationships regardless of part directories. Unreferenced `xl/drawings/*.xml` parts and their relationship files are also omitted, without adding notices; comment VML remains. Missing referenced parts and ambiguous case-equivalent ZIP entries fail the preview. Comments and Table metadata survive parsing but have no dedicated preview controls. The patch covers the Node sources and `dist/exceljs.js`; its browser entry selects that patched bundle. Dependency upgrades must preserve both entry paths and pass the [independent-writer regressions and fuzz diagnostics](tests/fuzz/README.md).
|
||||
|
||||
<a id="office-preview"></a>
|
||||
## Office preview
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别
|
||||
|
||||
只读公式栏按字面显示公式和单元格文本。复制时保留 HTML 表格,并转义单元格内容,包括已保存的公式结果。[FortuneSheet 补丁决策](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md)说明升级依赖时保留这些行为与工作表选区的要求。
|
||||
|
||||
固定版本的 [ExcelJS 补丁](../../../patches/[email protected])通过包内关系解析工作簿、样式、共享字符串、工作表、批注、Table 和 VML,支持绝对及相对目标,以及 ASCII 大小写等价的部件名,并按命名空间 URI 识别 SpreadsheetML 和 VML 名称。Strict OOXML 的 SpreadsheetML 和关系 URI 映射到相同的已支持预览功能;这不代表完整支持 Strict 标准。XML 部件支持 UTF-8 及两种字节序的 UTF-16;CDATA 按字面文本读取。绘图和条件格式提示按关系查找内容,不依赖部件目录。未被引用的 `xl/drawings/*.xml` 部件及其关系文件也会被移除,但不会产生提示;批注 VML 保持不变。缺失引用部件或存在大小写等价的歧义 ZIP 条目会使预览失败。解析保留批注和 Table 元数据,但预览器没有专门展示它们的控件。补丁同时覆盖 Node 源码和 `dist/exceljs.js`,浏览器入口选择已修补的 bundle。升级依赖时必须保留两个入口的行为,并通过[独立写入器回归测试和 fuzz 诊断](tests/fuzz/README.zh.md)。
|
||||
|
||||
<a id="office-preview"></a>
|
||||
## Office 预览
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** XLSX preview copies without DrawingML parts; worksheet XML and source bytes remain untouched. */
|
||||
import { strFromU8, unzipSync, zipSync, type Unzipped } from 'fflate'
|
||||
import { unzipSync, zipSync, type Unzipped } from 'fflate'
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
import type { ExcelUnsupportedFeature } from './model.ts'
|
||||
|
||||
@@ -8,13 +8,21 @@ export class XlsxPreviewArchive {
|
||||
/** Detected workbook content that the preview does not display. */
|
||||
readonly unsupportedFeatures = new Set<ExcelUnsupportedFeature>()
|
||||
private readonly files: Unzipped
|
||||
private readonly parts = new Map<string, Uint8Array>()
|
||||
|
||||
/**
|
||||
* Read an archive without modifying the borrowed source buffer.
|
||||
* @param bytes - Complete XLSX source bytes.
|
||||
* @throws When ZIP entries have ASCII case-equivalent names.
|
||||
*/
|
||||
constructor(private readonly bytes: Uint8Array<ArrayBuffer>) {
|
||||
this.files = unzipSync(bytes)
|
||||
for (const [path, bytes] of Object.entries(this.files)) {
|
||||
if (path.endsWith('/')) continue
|
||||
const key = partKey(path)
|
||||
if (this.parts.has(key)) throw new Error(`Ambiguous XLSX part: ${path}`)
|
||||
this.parts.set(key, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,18 +31,36 @@ export class XlsxPreviewArchive {
|
||||
*/
|
||||
withoutDrawings(): Uint8Array<ArrayBuffer> {
|
||||
const entries = Object.entries(this.files)
|
||||
const retained = entries.filter(([path, bytes]) => {
|
||||
if (/^xl\/worksheets\/[^/]+\.xml$/.test(path)) {
|
||||
this.inspectContent(bytes)
|
||||
return true
|
||||
}
|
||||
if (/^xl\/drawings\/[^/]+\.xml$/.test(path)) {
|
||||
this.inspectContent(bytes)
|
||||
} else if (!/^xl\/drawings\/_rels\/[^/]+\.xml\.rels$/.test(path)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
const omitted = new Set<string>()
|
||||
for (const [path, bytes] of entries) {
|
||||
// ExcelJS also discovers unreferenced drawings in its conventional directory.
|
||||
if (/^xl\/drawings\/(?:[^/]+\.xml|_rels\/[^/]+\.xml\.rels)$/i.test(path)) omitted.add(partKey(path))
|
||||
const match = /^(.*\/)?_rels\/([^/]+)\.rels$/i.exec(path)
|
||||
if (match === null) continue
|
||||
const owner = `${match[1] ?? ''}${match[2]}`
|
||||
new XMLParser({
|
||||
removeNSPrefix: true, ignoreAttributes: false, attributeNamePrefix: '', parseAttributeValue: false,
|
||||
updateTag: (tag: string, _path, attributes: Record<string, unknown>) => {
|
||||
const type = typeof attributes.Type === 'string'
|
||||
? attributes.Type.replace(/^http:\/\/purl\.oclc\.org\/ooxml\/officeDocument\/relationships\//, 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/') : undefined
|
||||
if (tag !== 'Relationship' || attributes.TargetMode === 'External') return tag
|
||||
const drawing = type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing'
|
||||
if (!drawing && type !== 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet') return tag
|
||||
if (typeof attributes.Target !== 'string') throw new Error('Missing XLSX relationship target')
|
||||
const target = resolvePart(owner, attributes.Target)
|
||||
const content = this.parts.get(partKey(target))
|
||||
if (content === undefined) throw new Error(`Missing XLSX part: ${target}`)
|
||||
this.inspectContent(content)
|
||||
if (drawing) {
|
||||
omitted.add(partKey(target))
|
||||
const split = target.lastIndexOf('/')
|
||||
omitted.add(partKey(`${target.slice(0, split + 1)}_rels/${target.slice(split + 1)}.rels`))
|
||||
}
|
||||
return tag
|
||||
},
|
||||
}).parse(decodeXml(bytes))
|
||||
}
|
||||
const retained = entries.filter(([path]) => !omitted.has(partKey(path)))
|
||||
return retained.length === entries.length ? this.bytes : new Uint8Array(zipSync(Object.fromEntries(retained), { level: 0 }))
|
||||
}
|
||||
|
||||
@@ -52,6 +78,33 @@ export class XlsxPreviewArchive {
|
||||
if (tag === 'sheetData') return false
|
||||
return tag
|
||||
},
|
||||
}).parse(strFromU8(bytes))
|
||||
}).parse(decodeXml(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/** OPC compares part names using ASCII case equivalence, leaving ZIP names intact. */
|
||||
function partKey(path: string): string {
|
||||
return path.replace(/[A-Z]/g, character => character.toLowerCase())
|
||||
}
|
||||
|
||||
/** Decode the mandatory XML encodings without rewriting the archived bytes. */
|
||||
function decodeXml(bytes: Uint8Array): string {
|
||||
const encoding = (bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0x3c && bytes[1] === 0)
|
||||
? 'utf-16le'
|
||||
: (bytes[0] === 0xfe && bytes[1] === 0xff) || (bytes[0] === 0 && bytes[1] === 0x3c)
|
||||
? 'utf-16be' : 'utf-8'
|
||||
return new TextDecoder(encoding, { fatal: true }).decode(bytes)
|
||||
}
|
||||
|
||||
/** Resolve internal relationship targets without accepting traversal above the package root. */
|
||||
function resolvePart(owner: string, target: string): string {
|
||||
const parts = target.startsWith('/') ? [] : owner.split('/').slice(0, -1)
|
||||
for (const segment of target.split('/')) {
|
||||
if (segment === '' || segment === '.') continue
|
||||
if (segment === '..') {
|
||||
if (parts.length === 0) throw new Error('XLSX relationship escapes package')
|
||||
parts.pop()
|
||||
} else parts.push(segment)
|
||||
}
|
||||
return parts.join('/')
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Independent-writer XLSX files shared by parser and shipped-browser regressions. */
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
/** Five isolated failures and one workbook combining their features. */
|
||||
export const excelOpcCases = ['comments', 'table', 'prefix', 'part-name', 'relative', 'combined'] as const
|
||||
|
||||
/**
|
||||
* Read an Openpyxl or XlsxWriter fixture without regenerating it with the parser under test.
|
||||
* @param name - Retained fixture name.
|
||||
* @returns Complete XLSX bytes owned by the caller.
|
||||
*/
|
||||
export async function excelOpcFixture(name: typeof excelOpcCases[number]): Promise<Uint8Array<ArrayBuffer>> {
|
||||
return new Uint8Array(await readFile(new URL(`./fixtures/excel-opc/${name}.xlsx`, import.meta.url)))
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/** OPC part resolution and namespace aliases preserve independent-writer workbook contents. */
|
||||
import { createRequire } from 'node:module'
|
||||
import ExcelJS from 'exceljs'
|
||||
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Config } from '../src/config.ts'
|
||||
import { convertExcel } from '../src/client/excel/convert.ts'
|
||||
import { XlsxPreviewArchive } from '../src/client/excel/xlsx-archive.ts'
|
||||
import { excelOpcCases, excelOpcFixture } from './excel-opc-fixture.ts'
|
||||
import { excelFixture, excelHtmlFixture } from './excel-fixture.ts'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const browserExcel = require('exceljs/dist/exceljs.js') as typeof ExcelJS
|
||||
const limits = Config({}).excel
|
||||
const relsPath = 'xl/worksheets/_rels/budget.xml.rels'
|
||||
|
||||
describe.each([['Node', ExcelJS], ['browser bundle', browserExcel]] as const)('%s ExcelJS entry', (_name, parser) => {
|
||||
it.each(excelOpcCases)('opens %s without changing source bytes', async (name) => {
|
||||
const source = await excelOpcFixture(name)
|
||||
const before = source.slice()
|
||||
const archive = new XlsxPreviewArchive(source)
|
||||
const input = archive.withoutDrawings()
|
||||
const workbook = new parser.Workbook()
|
||||
await workbook.xlsx.load(input.buffer, { ignoreNodes: ['drawing'] })
|
||||
const sheet = workbook.getWorksheet('数据')!
|
||||
expect(sheet.getCell('A1').value).toBe('Item')
|
||||
expect(sheet.getCell('B2').value).toBe(42)
|
||||
if (name === 'comments' || name === 'combined') {
|
||||
expect(sheet.getCell('B2').note).toBe('生成的批注')
|
||||
}
|
||||
if (name === 'table' || name === 'combined') {
|
||||
expect(sheet.getTable('DataTable')).toMatchObject({ table: { name: 'DataTable', tableRef: 'A1:B3' } })
|
||||
}
|
||||
expect(source).toEqual(before)
|
||||
})
|
||||
|
||||
it('binds comment VML independently of relationship order and ZIP entry order', async () => {
|
||||
const source = await excelOpcFixture('combined')
|
||||
const files = unzipSync(new XlsxPreviewArchive(source).withoutDrawings())
|
||||
const comments = 'xl/comments/comment1.xml'
|
||||
files[comments] = strToU8(strFromU8(files[comments]!).replace('<text><t>', '<text><r><rPr><b/></rPr><t>').replace('</t></text>', '</t></r></text>'))
|
||||
const vml = 'xl/drawings/commentsDrawing1.vml'
|
||||
files[vml] = strToU8(strFromU8(files[vml]!).replace('insetmode="auto"', 'insetmode="custom"'))
|
||||
const xml = strFromU8(files[relsPath]!)
|
||||
const relations = xml.match(/<Relationship\b[^>]*\/>/g)!
|
||||
files[relsPath] = strToU8(xml.replace(relations.join(''), [...relations].reverse().join('')))
|
||||
const workbook = new parser.Workbook()
|
||||
await workbook.xlsx.load(new Uint8Array(zipSync(Object.fromEntries(Object.entries(files).reverse()))).buffer, { ignoreNodes: ['drawing'] })
|
||||
expect(workbook.getWorksheet('数据')!.getCell('B2').note).toMatchObject({
|
||||
texts: [{ text: '生成的批注', font: { bold: true } }], margins: { insetmode: 'custom' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('retains styles, formulas, hidden sheets and notices in the combined workbook', async () => {
|
||||
const source = await excelOpcFixture('combined')
|
||||
const result = await convertExcel(source, 'xlsx', limits)
|
||||
const sheet = result.sheets[0]!
|
||||
expect(result.sheets.map(sheet => [sheet.name, sheet.hide])).toEqual([['数据', 0], ['隐藏页', 1]])
|
||||
expect(sheet.celldata!.find(cell => cell.r === 1 && cell.c === 1)!.v).toMatchObject({ v: 42, bl: 1, fc: '#112233', bg: '#FFFF00' })
|
||||
expect(sheet.celldata!.find(cell => cell.r === 4 && cell.c === 0)!.v?.v).toBe(' <s:pic/> & 中文😀\n tail ')
|
||||
expect(sheet.celldata!.find(cell => cell.r === 5 && cell.c === 0)!.v?.f).toBe('=SUM(B2:B3)')
|
||||
expect(sheet.config).toMatchObject({ merge: { '29_7': { r: 29, c: 7, rs: 1, cs: 3 } }, rowhidden: { 2: 0 }, colhidden: { 1: 0 } })
|
||||
expect(sheet.frozen).toMatchObject({ type: 'rangeBoth', range: { row_focus: 0, column_focus: 0 } })
|
||||
expect(result.unsupportedFeatures).toEqual(['charts', 'conditionalFormatting'])
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
await workbook.xlsx.load(new XlsxPreviewArchive(source).withoutDrawings().buffer, { ignoreNodes: ['drawing'] })
|
||||
expect(workbook.getWorksheet('数据')!.getCell('A2').hyperlink).toBe('https://example.com/?a=1&b=2')
|
||||
})
|
||||
|
||||
it.each([excelFixture, excelHtmlFixture])('preserves cells and attributes under equivalent namespace aliases', async (fixture) => {
|
||||
const source = await fixture()
|
||||
const files = unzipSync(source)
|
||||
for (const [path, bytes] of Object.entries(files)) {
|
||||
if (!/^xl\/worksheets\/[^/]+\.xml$/.test(path)) continue
|
||||
files[path] = strToU8(strFromU8(bytes)
|
||||
.replace('xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"', 'xmlns:s="http://schemas.openxmlformats.org/spreadsheetml/2006/main"')
|
||||
.replace(/<(\/?)([A-Za-z_][\w.-]*)(?=[\s/>])/g, '<$1s:$2')
|
||||
.replaceAll('xmlns:r=', 'xmlns:link=').replaceAll(' r:id=', ' link:id='))
|
||||
}
|
||||
expect(await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).toEqual(await convertExcel(source, 'xlsx', limits))
|
||||
})
|
||||
|
||||
it.each(['part', 'relationship', 'escape', 'external'] as const)('rejects a missing or invalid worksheet %s instead of dropping the sheet', async (defect) => {
|
||||
const files = unzipSync(await excelFixture())
|
||||
if (defect === 'part') delete files['xl/worksheets/sheet2.xml']
|
||||
else {
|
||||
const path = 'xl/_rels/workbook.xml.rels'
|
||||
const xml = strFromU8(files[path]!)
|
||||
files[path] = strToU8(defect === 'relationship' ? xml.replace(/<Relationship\b[^>]*Target="worksheets\/sheet2.xml"[^>]*\/>/, '')
|
||||
: xml.replace('Target="worksheets/sheet2.xml"', defect === 'escape' ? 'Target="../../sheet2.xml"' : 'Target="https://example.com/sheet2.xml" TargetMode="External"'))
|
||||
}
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
|
||||
it.each(['xl/comments/comment1.xml', 'xl/tables/table1.xml', 'xl/drawings/commentsDrawing1.vml'])('rejects missing referenced metadata %s', async (path) => {
|
||||
const files = unzipSync(await excelOpcFixture('combined'))
|
||||
Reflect.deleteProperty(files, path)
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Opening regressions derived from the independent-writer combined workbook. */
|
||||
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate'
|
||||
import { excelOpcFixture } from './excel-opc-fixture.ts'
|
||||
|
||||
/** Supported URI aliases, ASCII case-equivalent part names and orphan DrawingML. */
|
||||
export const excelOpeningCases = ['strict', 'case-root', 'case-parts', 'orphan-drawing'] as const
|
||||
|
||||
/**
|
||||
* Change only the package property under test in an independent-writer workbook.
|
||||
* @param name - Opening regression to construct.
|
||||
* @returns Complete XLSX bytes owned by the caller.
|
||||
*/
|
||||
export async function excelOpeningFixture(name: typeof excelOpeningCases[number]): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const files = unzipSync(await excelOpcFixture('combined'))
|
||||
const result: Record<string, Uint8Array> = {}
|
||||
for (const [path, bytes] of Object.entries(files)) {
|
||||
let xml = /\.(xml|rels|vml)$/.test(path) ? strFromU8(bytes) : undefined
|
||||
let target = path
|
||||
if (xml !== undefined) {
|
||||
if (name === 'strict') {
|
||||
xml = xml.replaceAll('http://schemas.openxmlformats.org/spreadsheetml/2006/main', 'http://purl.oclc.org/ooxml/spreadsheetml/main')
|
||||
.replaceAll('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'http://purl.oclc.org/ooxml/officeDocument/relationships')
|
||||
} else if (name === 'case-root' && path === '_rels/.rels') {
|
||||
xml = xml.replace('Target="xl/workbook.xml"', 'Target="XL/WORKBOOK.XML"')
|
||||
} else if (name === 'case-parts') {
|
||||
if (!path.startsWith('docProps/') && !path.startsWith('xl/theme/')) target = path.toUpperCase()
|
||||
if (path.endsWith('.rels')) xml = xml.replace(/Target="([^"]+)"/g, (attribute, value: string) => value.includes('://') ? attribute : `Target="${value.toLowerCase()}"`)
|
||||
} else if (name === 'orphan-drawing') {
|
||||
if (path.endsWith('.rels')) xml = xml.replace(/<Relationship\b[^>]*Type="[^"]*\/drawing"[^>]*\/>/g, '')
|
||||
if (path.startsWith('xl/worksheets/')) xml = xml.replace(/<(?:\w+:)?drawing\b[^>]*\/>/g, '')
|
||||
}
|
||||
}
|
||||
result[target] = xml === undefined ? bytes : strToU8(xml)
|
||||
}
|
||||
return new Uint8Array(zipSync(result))
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/** URI and part-name aliases and orphan drawings preserve usable workbook previews. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
import ExcelJS from 'exceljs'
|
||||
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Config } from '../src/config.ts'
|
||||
import { convertExcel } from '../src/client/excel/convert.ts'
|
||||
import { XlsxPreviewArchive } from '../src/client/excel/xlsx-archive.ts'
|
||||
import { excelOpeningCases, excelOpeningFixture } from './excel-opening-fixture.ts'
|
||||
import { excelOpcFixture } from './excel-opc-fixture.ts'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const browserExcel = require('exceljs/dist/exceljs.js') as typeof ExcelJS
|
||||
const limits = Config({}).excel
|
||||
|
||||
describe.each([['Node', ExcelJS], ['browser bundle', browserExcel]] as const)('%s opening regressions', (_name, parser) => {
|
||||
it.each(excelOpeningCases)('opens %s with cells, styles, comments, Tables and hyperlinks', async (name) => {
|
||||
const source = await excelOpeningFixture(name)
|
||||
const original = source.slice()
|
||||
const retained = new XlsxPreviewArchive(source).withoutDrawings()
|
||||
const workbook = new parser.Workbook()
|
||||
await workbook.xlsx.load(retained.buffer, { ignoreNodes: ['drawing'] })
|
||||
const sheet = workbook.getWorksheet('数据')
|
||||
assert.ok(sheet)
|
||||
expect(sheet.getCell('A1').value).toBe('Item')
|
||||
expect(sheet.getCell('B2').value).toBe(42)
|
||||
expect(sheet.getCell('B2').font).toMatchObject({ bold: true, color: { argb: 'FF112233' } })
|
||||
expect(sheet.getCell('B2').note).toBe('生成的批注')
|
||||
expect(sheet.getTable('DataTable')).toMatchObject({ table: { tableRef: 'A1:B3' } })
|
||||
expect(sheet.getCell('A2').hyperlink).toBe('https://example.com/?a=1&b=2')
|
||||
expect(workbook.getWorksheet('隐藏页')?.state).toBe('hidden')
|
||||
const originalParts = unzipSync(source)
|
||||
for (const [path, bytes] of Object.entries(unzipSync(retained))) expect(bytes, path).toEqual(originalParts[path])
|
||||
expect(source).toEqual(original)
|
||||
})
|
||||
|
||||
it.each(['xl/workbook.xml', '_rels/.rels'])('rejects ambiguous ASCII case-equivalent entries for %s', async (path) => {
|
||||
const files = unzipSync(await excelOpcFixture('combined'))
|
||||
const bytes = files[path]
|
||||
assert.ok(bytes)
|
||||
files[path.toUpperCase()] = bytes
|
||||
const source = new Uint8Array(zipSync(files))
|
||||
await expect(new parser.Workbook().xlsx.load(source.buffer)).rejects.toThrow(/Ambiguous.*part/i)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(excelOpeningCases)('preserves the complete preview and referenced-content notices for %s', async (name) => {
|
||||
const expected = await convertExcel(await excelOpcFixture('combined'), 'xlsx', limits)
|
||||
if (name === 'orphan-drawing') expected.unsupportedFeatures = ['conditionalFormatting']
|
||||
expect(await convertExcel(await excelOpeningFixture(name), 'xlsx', limits)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('omits orphan DrawingML and its relationships while retaining comment VML', async () => {
|
||||
const source = await excelOpeningFixture('orphan-drawing')
|
||||
const original = unzipSync(source)
|
||||
expect(original['xl/drawings/drawing1.xml']).toBeDefined()
|
||||
expect(original['xl/drawings/_rels/drawing1.xml.rels']).toBeDefined()
|
||||
const archive = new XlsxPreviewArchive(source)
|
||||
const retained = unzipSync(archive.withoutDrawings())
|
||||
expect(retained['xl/drawings/drawing1.xml']).toBeUndefined()
|
||||
expect(retained['xl/drawings/_rels/drawing1.xml.rels']).toBeUndefined()
|
||||
expect(retained['xl/drawings/commentsDrawing1.vml']).toEqual(original['xl/drawings/commentsDrawing1.vml'])
|
||||
expect([...archive.unsupportedFeatures]).toEqual(['conditionalFormatting'])
|
||||
})
|
||||
|
||||
it('rejects ambiguous part names before filtering can hide either entry', async () => {
|
||||
const files = unzipSync(await excelOpcFixture('combined'))
|
||||
const drawing = files['xl/drawings/drawing1.xml']
|
||||
assert.ok(drawing)
|
||||
files['XL/DRAWINGS/DRAWING1.XML'] = drawing
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
|
||||
it('does not fold non-ASCII characters when resolving part names', async () => {
|
||||
const files = unzipSync(await excelOpcFixture('combined'))
|
||||
const path = 'xl/_rels/workbook.xml.rels'
|
||||
const rels = files[path]
|
||||
const sheet = files['xl/worksheets/budget.xml']
|
||||
assert.ok(rels)
|
||||
assert.ok(sheet)
|
||||
files[path] = strToU8(strFromU8(rels).replace('worksheets/budget.xml', 'worksheets/Ä.xml'))
|
||||
files['xl/worksheets/ä.xml'] = sheet
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Equivalent XML encodings and package layouts produced with XlsxWriter. */
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
/** Baseline and equivalent workbooks covering XML text and relocated core parts. */
|
||||
export const excelXmlCases = ['rich', 'utf16-sheet', 'utf16-metadata', 'workbook-path', 'styles-path', 'strings-path', 'cdata', 'parts-path'] as const
|
||||
|
||||
/**
|
||||
* Read committed independent-writer bytes for parser and browser tests.
|
||||
* @param name - Retained fixture name.
|
||||
* @returns Complete XLSX bytes owned by the caller.
|
||||
*/
|
||||
export async function excelXmlFixture(name: typeof excelXmlCases[number]): Promise<Uint8Array<ArrayBuffer>> {
|
||||
return new Uint8Array(await readFile(new URL(`./fixtures/excel-xml/${name}.xlsx`, import.meta.url)))
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/** XML decoding and OPC discovery preserve cells, metadata and preview notices. */
|
||||
import { createRequire } from 'node:module'
|
||||
import ExcelJS from 'exceljs'
|
||||
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Config } from '../src/config.ts'
|
||||
import { convertExcel } from '../src/client/excel/convert.ts'
|
||||
import { XlsxPreviewArchive } from '../src/client/excel/xlsx-archive.ts'
|
||||
import { excelXmlCases, excelXmlFixture } from './excel-xml-fixture.ts'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const browserExcel = require('exceljs/dist/exceljs.js') as typeof ExcelJS
|
||||
const limits = Config({}).excel
|
||||
|
||||
describe.each([['Node', ExcelJS], ['browser bundle', browserExcel]] as const)('%s XML reader', (_name, parser) => {
|
||||
it.each(excelXmlCases)('preserves comments and Tables in %s', async (name) => {
|
||||
const source = await excelXmlFixture(name)
|
||||
const original = source.slice()
|
||||
const archive = new XlsxPreviewArchive(source)
|
||||
const retained = archive.withoutDrawings()
|
||||
const workbook = new parser.Workbook()
|
||||
await workbook.xlsx.load(retained.buffer, { ignoreNodes: ['drawing'] })
|
||||
const sheet = workbook.getWorksheet('数据')!
|
||||
expect(sheet.getCell('A5').value).toBe(' <chart/> & 中文😀\n tail ')
|
||||
expect(sheet.getCell('A6').value).toEqual({ formula: 'SUM(B2:B3)', result: 57 })
|
||||
expect(sheet.getCell('B2').note).toMatchObject({ texts: [{ text: '生成的批注' }] })
|
||||
expect(sheet.getTable('Table1')).toMatchObject({ table: { name: 'Table1', tableRef: 'A1:B3' } })
|
||||
expect(sheet.getCell('B2').font).toMatchObject({ bold: true, color: { argb: 'FF112233' } })
|
||||
expect(workbook.getWorksheet('隐藏页')!.state).toBe('hidden')
|
||||
const originalParts = unzipSync(source)
|
||||
for (const [path, bytes] of Object.entries(unzipSync(retained))) expect(bytes, path).toEqual(originalParts[path])
|
||||
expect(source).toEqual(original)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(excelXmlCases)('preserves the complete preview for %s', async (name) => {
|
||||
const expected = await convertExcel(await excelXmlFixture('rich'), 'xlsx', limits)
|
||||
expect(await convertExcel(await excelXmlFixture(name), 'xlsx', limits)).toEqual(expected)
|
||||
expect(expected.unsupportedFeatures).toEqual(['charts', 'images', 'shapes', 'conditionalFormatting'])
|
||||
const sheet = expected.sheets[0]!
|
||||
expect(sheet.celldata!.find(cell => cell.r === 1 && cell.c === 1)!.v).toMatchObject({ v: 42, bl: 1, fc: '#112233', bg: '#FFFF00' })
|
||||
expect(sheet.config).toMatchObject({ merge: { '29_7': { r: 29, c: 7, rs: 1, cs: 3 } }, rowhidden: { 2: 0 }, colhidden: { 1: 0 } })
|
||||
expect(sheet.frozen).toMatchObject({ type: 'rangeBoth', range: { row_focus: 0, column_focus: 0 } })
|
||||
})
|
||||
|
||||
it.each(['utf-16le', 'utf-16be'] as const)('decodes %s parts and relationships with and without a BOM', async (encoding) => {
|
||||
const source = await excelXmlFixture('rich')
|
||||
const expected = await convertExcel(source, 'xlsx', limits)
|
||||
for (const bom of [false, true]) {
|
||||
const files = unzipSync(source)
|
||||
for (const [path, bytes] of Object.entries(files)) {
|
||||
if (!/\.(xml|rels|vml)$/.test(path)) continue
|
||||
const xml = strFromU8(bytes).replace(/encoding="UTF-8"/i, 'encoding="UTF-16"')
|
||||
const encoded = Buffer.from(`${bom ? '\uFEFF' : ''}${xml}`, 'utf16le')
|
||||
if (encoding === 'utf-16be') encoded.swap16()
|
||||
files[path] = encoded
|
||||
}
|
||||
expect(await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).toEqual(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['xl/workbook.xml', 'xl/styles.xml', 'xl/sharedStrings.xml', '_rels/.rels', 'xl/_rels/workbook.xml.rels'])('rejects missing core part %s', async (path) => {
|
||||
const files = unzipSync(await excelXmlFixture('rich'))
|
||||
Reflect.deleteProperty(files, path)
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
|
||||
it('ignores unrelated binary parts without trying to decode them as XML', async () => {
|
||||
const source = await excelXmlFixture('rich')
|
||||
const files = unzipSync(source)
|
||||
files['xl/vbaProject.bin'] = Uint8Array.of(0xff, 0x80, 0x81)
|
||||
expect(await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).toEqual(await convertExcel(source, 'xlsx', limits))
|
||||
})
|
||||
|
||||
it('preserves text split across escaped text and CDATA segments', async () => {
|
||||
const source = await excelXmlFixture('rich')
|
||||
const files = unzipSync(source)
|
||||
const path = 'xl/sharedStrings.xml'
|
||||
files[path] = strToU8(strFromU8(files[path]!).replace('Item</t>', 'I<![CDATA[t]]>em</t>'))
|
||||
expect(await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).toEqual(await convertExcel(source, 'xlsx', limits))
|
||||
})
|
||||
|
||||
it('finds a drawing owned by a part at the package root', () => {
|
||||
const archive = new XlsxPreviewArchive(new Uint8Array(zipSync({
|
||||
'_rels/root.xml.rels': strToU8('<Relationships><Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="drawing.xml"/></Relationships>'),
|
||||
'drawing.xml': strToU8('<wsDr><chart/></wsDr>'),
|
||||
'_rels/drawing.xml.rels': strToU8('<Relationships/>'),
|
||||
})))
|
||||
expect(Object.keys(unzipSync(archive.withoutDrawings()))).toEqual(['_rels/root.xml.rels'])
|
||||
expect([...archive.unsupportedFeatures]).toEqual(['charts'])
|
||||
})
|
||||
|
||||
it('rejects a worksheet relationship with no target', async () => {
|
||||
const files = unzipSync(await excelXmlFixture('rich'))
|
||||
const path = 'xl/_rels/workbook.xml.rels'
|
||||
files[path] = strToU8(strFromU8(files[path]!).replace('Target="worksheets/sheet1.xml"', ''))
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
|
||||
it.each(['missing', 'external', 'duplicate'] as const)('rejects a %s officeDocument relationship', async (defect) => {
|
||||
const files = unzipSync(await excelXmlFixture('rich'))
|
||||
const xml = strFromU8(files['_rels/.rels']!)
|
||||
const relation = xml.match(/<Relationship\b[^>]*officeDocument"[^>]*\/>/)![0]
|
||||
files['_rels/.rels'] = strToU8(xml.replace(relation, defect === 'missing' ? '' : defect === 'duplicate' ? relation + relation : relation.replace('/>', ' TargetMode="External"/>')))
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
|
||||
it('rejects malformed UTF-16 without replacing invalid code units', async () => {
|
||||
const files = unzipSync(await excelXmlFixture('rich'))
|
||||
files['xl/worksheets/sheet1.xml'] = Uint8Array.of(0xff, 0xfe, 0x3c)
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/tests/fuzz/README.md
|
||||
README.md: 7a95b5cedb7dbcbfbc1f0773513ff6488440fd98
|
||||
README.zh.md: 608acf3d1eb83028e3a916dff3e87cc0e88b2ee6
|
||||
@@ -0,0 +1,46 @@
|
||||
# XLSX fuzz diagnostics
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Generate spreadsheet files with Openpyxl and XlsxWriter, then compare the source XLSX adapter against independently recorded cell values, formulas, and unsupported-content categories. This explicit diagnostic campaign retains failures and exits nonzero; it is not part of the default unit suite.
|
||||
|
||||
## Run a campaign
|
||||
|
||||
Install workspace dependencies and use a Python interpreter with Openpyxl, XlsxWriter, and Pillow. In PowerShell, set `$excelFuzzPython` to that interpreter's executable path. Run from the repository root with a new output directory:
|
||||
|
||||
```powershell
|
||||
& $excelFuzzPython packages/client/ui-sidebar-documentpreview/tests/fuzz/generate.py .artifacts/excel-fuzz-new --seed 4863 --random-cases 400
|
||||
pnpm exec tsx packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts .artifacts/excel-fuzz-new
|
||||
```
|
||||
|
||||
The seed controls feature combinations, values, and byte mutations. ZIP metadata is fixed; reproducibility also requires the writer versions recorded in `manifest.json`. The generator validates internal relationship targets and reloads oracle cells with Openpyxl before classifying a workbook as valid. Rejected candidates remain in `generatorErrors`; random byte mutations are classified separately.
|
||||
|
||||
Replay selected cases from an existing corpus:
|
||||
|
||||
```sh
|
||||
pnpm exec tsx packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts .artifacts/excel-fuzz-new --only openpyxl-table --report table.json
|
||||
```
|
||||
|
||||
The runner gives each input a disposable-worker deadline of 10 seconds and checks input immutability, cell values, formula caches, and notice ordering/uniqueness. `--timeout-ms` changes that diagnostic deadline. Reports retain every outcome; `repros/` contains representative failures with fewer feature combinations, not a claim of globally minimal inputs. Accepted damaged ZIPs are informational and need separate inspection.
|
||||
|
||||
## Compare an older adapter
|
||||
|
||||
`--baseline-ref <commit-or-ref> --report baseline.json` reads that revision's `xlsx.ts` from Git and disables notice comparisons. It still uses the current dependency versions and shared conversion helpers; it cannot reproduce an unpatched ExcelJS baseline after installing the patch. Retain reports from the original dependency state or run that revision in a separate checkout. The temporary baseline module is removed after its worker exits. This compares source parsing, not FortuneSheet browser rendering or the shipped Web profile.
|
||||
|
||||
## Retained regressions
|
||||
|
||||
The [OPC fixtures](../fixtures/excel-opc/) contain five isolated failures copied from the seed 4863 corpus and a combined Openpyxl workbook. Openpyxl 3.1.5 produces comments, Tables, namespace-prefix and part-name cases; XlsxWriter 3.2.9 produces the relative-target case. The combined workbook adds chart, conditional formatting, frozen headings, hidden rows/columns/sheet, a merge, styles, a hyperlink, literal XML-looking text, and a formula, then applies `sheet-prefix` and `sheet-filename` mutations. Openpyxl independently validates the saved inputs. Unit and browser tests read these committed bytes without Python.
|
||||
|
||||
The [XML fixtures](../fixtures/excel-xml/) retain one XlsxWriter 3.2.9 workbook and seven equivalent encodings: UTF-16 worksheet or metadata XML, relocated workbook/styles/shared strings, CDATA text, and relocated worksheet/drawing directories. Each retains a formula cache, Unicode and XML-looking text, comment, Table, styles, merge, frozen headings, hidden content, and all four notice categories. When upgrading ExcelJS, compare the complete preview and comment/Table metadata through both Node and browser entries, then run `apps/web/tests/excel-opc.e2e.ts` against the built Worker. Keep original corpus reports; save replay results under a new `--report` name and inspect ZIP directory integrity separately from independent-reader acceptance.
|
||||
|
||||
The [opening fixtures](../excel-opening-fixture.ts) derive Strict URI aliases, root-target and part-name case variants, and orphan DrawingML from the combined Openpyxl file. These transformations retain cell data and run through both parser entries, full preview comparison, and the shipped browser Worker. Ambiguous ASCII case-equivalent entries are rejected; non-ASCII names remain distinct.
|
||||
|
||||
Run the Node and browser-bundle parser regressions:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run packages/client/ui-sidebar-documentpreview/tests/excel-opening.client.spec.ts packages/client/ui-sidebar-documentpreview/tests/excel-opc.client.spec.ts packages/client/ui-sidebar-documentpreview/tests/excel-xml.client.spec.ts
|
||||
```
|
||||
|
||||
Fuzz reports record each input's SHA-256, whether it opened, and the worktree status. `validOpened` and `validRejected` separate opening failures from value differences; strict exit status still rejects either defect. Known 1904 time/duration offsets and empty-string formula-cache differences remain diagnostic failures. Corrupt inputs accepted by the parser remain informational.
|
||||
|
||||
Maintain the ExcelJS patch with `pnpm patch` and `pnpm patch-commit` against the published package. Installed dependency trees contain generated launchers that must not enter the patch. Validate patch application in a fresh `pnpm patch --edit-dir <new-directory>` extraction before relying on tests against a cached installation.
|
||||
@@ -0,0 +1,46 @@
|
||||
# XLSX 模糊测试诊断
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
使用 Openpyxl 和 XlsxWriter 生成表格文件,再将源码 XLSX 适配器的结果与独立记录的单元格值、公式和不支持内容类别比较。此诊断任务需要显式运行,会保留失败结果并以非零状态退出;它不属于默认单元测试集。
|
||||
|
||||
## 运行测试
|
||||
|
||||
安装工作区依赖,并准备装有 Openpyxl、XlsxWriter 和 Pillow 的 Python 解释器。在 PowerShell 中,将 `$excelFuzzPython` 设置为该解释器的可执行文件路径。从仓库根目录运行,并使用新的输出目录:
|
||||
|
||||
```powershell
|
||||
& $excelFuzzPython packages/client/ui-sidebar-documentpreview/tests/fuzz/generate.py .artifacts/excel-fuzz-new --seed 4863 --random-cases 400
|
||||
pnpm exec tsx packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts .artifacts/excel-fuzz-new
|
||||
```
|
||||
|
||||
种子控制功能组合、数值和字节变异。ZIP 元数据保持固定;复现还依赖 `manifest.json` 记录的写入器版本。生成器先验证内部关系目标,并用 Openpyxl 重新读取预期单元格,再将工作簿归类为合法文件。被排除的候选保留在 `generatorErrors` 中;随机字节变异单独分类。
|
||||
|
||||
从已有样本集重跑选定用例:
|
||||
|
||||
```sh
|
||||
pnpm exec tsx packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts .artifacts/excel-fuzz-new --only openpyxl-table --report table.json
|
||||
```
|
||||
|
||||
执行器为每个输入设置 10 秒的可终止 Worker 截止时间,并检查输入不变性、单元格值、公式缓存,以及提示顺序和唯一性。`--timeout-ms` 可修改该诊断时限。报告保留全部结果;`repros/` 包含功能组合较少的代表性失败文件,不声称输入已达到全局最小。被接受的损坏 ZIP 仅作为观察结果,需要单独检查。
|
||||
|
||||
## 对比旧适配器
|
||||
|
||||
`--baseline-ref <commit-or-ref> --report baseline.json` 从 Git 读取该版本的 `xlsx.ts`,并禁用提示比较。它仍使用当前依赖版本和共用转换辅助函数,安装补丁后无法复现未修补 ExcelJS 的基线。须保留原依赖状态下的报告,或在独立检出目录中运行旧版本。Worker 退出后会删除临时基线模块。此对比检查源码解析,不覆盖 FortuneSheet 浏览器渲染或发布的 Web 配置。
|
||||
|
||||
## 保留的回归用例
|
||||
|
||||
[OPC fixture](../fixtures/excel-opc/) 包含从 seed 4863 语料复制的五类独立失败文件,以及一个组合 Openpyxl 工作簿。Openpyxl 3.1.5 生成批注、Table、命名空间前缀和部件名用例,XlsxWriter 3.2.9 生成相对目标用例。组合工作簿加入图表、条件格式、冻结表头、隐藏行/列/工作表、合并、样式、超链接、类似 XML 的字面文本和公式,再应用 `sheet-prefix` 与 `sheet-filename` 变异。Openpyxl 独立校验保存的输入。单元测试和浏览器测试读取已提交的字节,无需 Python。
|
||||
|
||||
[XML fixture](../fixtures/excel-xml/) 保留一个 XlsxWriter 3.2.9 工作簿及七种等价编码:UTF-16 工作表或元数据 XML、移动工作簿/样式/共享字符串部件、CDATA 文本,以及移动工作表/绘图目录。每个文件都保留公式缓存、Unicode 和类似 XML 的文本、批注、Table、样式、合并、冻结标题、隐藏内容和全部四类提示。升级 ExcelJS 时,应通过 Node 与浏览器入口比较完整预览及批注/Table 元数据,再对构建后的 Worker 运行 `apps/web/tests/excel-opc.e2e.ts`。保留原始语料报告;重放时使用新的 `--report` 文件名,并在独立读取器验证之外单独检查 ZIP 目录完整性。
|
||||
|
||||
[打开回归样例](../excel-opening-fixture.ts)基于 Openpyxl 组合文件派生 Strict URI 别名、根目标与部件名大小写变体,以及孤立 DrawingML。变换保留单元格数据,并通过两个解析入口、完整预览比较和实际发布的浏览器 Worker 验证。ASCII 大小写等价的歧义条目会被拒绝;非 ASCII 名称保持区分。
|
||||
|
||||
运行 Node 和浏览器 bundle 解析器回归测试:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run packages/client/ui-sidebar-documentpreview/tests/excel-opening.client.spec.ts packages/client/ui-sidebar-documentpreview/tests/excel-opc.client.spec.ts packages/client/ui-sidebar-documentpreview/tests/excel-xml.client.spec.ts
|
||||
```
|
||||
|
||||
fuzz 报告记录每个输入的 SHA-256、能否打开以及工作区状态。`validOpened` 和 `validRejected` 将打开失败与值差异分开,严格退出状态仍会拒绝这两类缺陷。已知的 1904 时间/累计时长偏移和空字符串公式缓存差异仍作为诊断失败。解析器接受的损坏输入继续作为观察结果单列。
|
||||
|
||||
通过 `pnpm patch` 和 `pnpm patch-commit` 对发布包维护 ExcelJS 补丁。已安装的依赖树包含生成的启动脚本,不得混入补丁。在依赖缓存安装上的测试结果之前,先用 `pnpm patch --edit-dir <new-directory>` 全新解包,验证补丁能够应用。
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Test-only Client parser isolation; the parent owns deadlines and termination. */
|
||||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import { convertExcel } from '../../src/client/excel/convert.ts'
|
||||
import type { ExcelLimits, ExcelPreview } from '../../src/client/excel/model.ts'
|
||||
|
||||
if (parentPort === null) throw new Error('The Excel fuzz worker requires a parent port')
|
||||
const port = parentPort
|
||||
const options = workerData as { source?: string }
|
||||
const baseline = options.source === undefined ? undefined : await import(options.source) as {
|
||||
convertXlsx: (bytes: Uint8Array<ArrayBuffer>, limits: ExcelLimits) => Promise<ExcelPreview>
|
||||
}
|
||||
port.on('message', (bytes: Uint8Array<ArrayBuffer>) => { void parseWorkbook(bytes) })
|
||||
|
||||
async function parseWorkbook(bytes: Uint8Array<ArrayBuffer>): Promise<void> {
|
||||
const before = bytes.slice()
|
||||
try {
|
||||
const limits = { maxBytes: 20_000_000, maxCells: 1_000_000, timeoutMs: 10_000 }
|
||||
const preview = baseline === undefined ? await convertExcel(bytes, 'xlsx', limits) : await baseline.convertXlsx(bytes, limits)
|
||||
port.postMessage({ ok: true, preview, inputUnchanged: before.every((value, index) => value === bytes[index]) })
|
||||
} catch (error) {
|
||||
const causes: string[] = []
|
||||
let current: unknown = error
|
||||
while (current instanceof Error && causes.length < 8) {
|
||||
causes.push(`${current.name}: ${current.message}`)
|
||||
current = current.cause
|
||||
}
|
||||
port.postMessage({ ok: false, causes, inputUnchanged: before.every((value, index) => value === bytes[index]) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Generate replayable XLSX cases using independent writers and equivalent ZIP/XML encodings."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
from datetime import datetime, time, timedelta
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import posixpath
|
||||
import random
|
||||
import re
|
||||
import warnings
|
||||
import zipfile
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import openpyxl
|
||||
from openpyxl.chart import BarChart, Reference
|
||||
from openpyxl.comments import Comment
|
||||
from openpyxl.drawing.image import Image
|
||||
from openpyxl.formatting.rule import ColorScaleRule, DataBarRule, IconSetRule, FormulaRule
|
||||
from openpyxl.styles import Font, PatternFill
|
||||
from openpyxl.utils.datetime import CALENDAR_MAC_1904, to_excel
|
||||
from openpyxl.worksheet.datavalidation import DataValidation
|
||||
from openpyxl.worksheet.table import Table, TableStyleInfo
|
||||
import xlsxwriter
|
||||
|
||||
FEATURES = ['chart', 'image', 'comment', 'conditional', 'conditional-databar', 'conditional-iconset', 'conditional-expression', 'table', 'validation', 'hyperlink',
|
||||
'merge', 'freeze', 'hidden', 'header', 'epoch1904', 'styled']
|
||||
ERRORS = ['#NULL!', '#DIV/0!', '#VALUE!', '#REF!', '#NAME?', '#NUM!', '#N/A',
|
||||
'#SPILL!', '#CALC!', '#GETTING_DATA']
|
||||
PNG = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=')
|
||||
MAIN_NS = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
|
||||
|
||||
|
||||
def fixed_zip(files, compression=zipfile.ZIP_DEFLATED, reverse=False, comment=b''):
|
||||
"""Fix ZIP metadata so the same seed yields byte-identical files."""
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, 'w', compression=compression) as archive:
|
||||
for name in sorted(files, reverse=reverse):
|
||||
entry = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0))
|
||||
entry.compress_type = compression
|
||||
content = files[name]
|
||||
if name == 'docProps/core.xml':
|
||||
# Openpyxl overwrites the modified time during save_workbook.
|
||||
content = re.sub(rb'(<dcterms:(?:created|modified)\b[^>]*>)[^<]*(</dcterms:(?:created|modified)>)', rb'\g<1>2020-01-01T00:00:00Z\g<2>', content)
|
||||
archive.writestr(entry, content)
|
||||
archive.comment = comment
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def unpack(data):
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
return {name: archive.read(name) for name in archive.namelist()}
|
||||
|
||||
|
||||
def validate(data, cells):
|
||||
"""Check internal relationship targets and independently reload all oracle cells."""
|
||||
files = unpack(data)
|
||||
for name, content in files.items():
|
||||
if name.endswith('.rels'):
|
||||
folder = '' if name == '_rels/.rels' else name.split('/_rels/')[0]
|
||||
for rel in ET.fromstring(content):
|
||||
if rel.get('TargetMode') == 'External':
|
||||
continue
|
||||
target = rel.attrib['Target']
|
||||
target = posixpath.normpath(target.lstrip('/') if target.startswith('/') else posixpath.join(folder, target))
|
||||
if target not in files:
|
||||
raise ValueError(f'Missing relationship target {name}: {target}')
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore', UserWarning)
|
||||
workbook = openpyxl.load_workbook(io.BytesIO(data), data_only=False)
|
||||
for cell in cells:
|
||||
actual = workbook[cell['sheet']].cell(cell['r'] + 1, cell['c'] + 1).value
|
||||
expected = cell.get('formula', cell.get('source', cell.get('value')))
|
||||
if isinstance(expected, dict):
|
||||
continue
|
||||
equivalent_number = type(actual) in (int, float) and type(expected) in (int, float) and math.isclose(actual, expected, rel_tol=1e-12, abs_tol=1e-12)
|
||||
if actual != expected and not equivalent_number:
|
||||
raise ValueError(f'Writer changed {cell}: {actual!r}')
|
||||
workbook.close()
|
||||
|
||||
|
||||
def workbook_bytes(writer, features, values, case_seed):
|
||||
"""Keep expected values independent of the preview implementation."""
|
||||
output = io.BytesIO()
|
||||
cells = []
|
||||
active = set(features)
|
||||
if writer == 'openpyxl':
|
||||
book = openpyxl.Workbook()
|
||||
book.properties.created = book.properties.modified = datetime(2020, 1, 1)
|
||||
if 'epoch1904' in active:
|
||||
book.epoch = CALENDAR_MAC_1904
|
||||
sheet = book.active
|
||||
sheet.title = '数据'
|
||||
else:
|
||||
book = xlsxwriter.Workbook(output, {'in_memory': True, 'date_1904': 'epoch1904' in active})
|
||||
book.set_properties({'created': datetime(2020, 1, 1)})
|
||||
sheet = book.add_worksheet('数据')
|
||||
|
||||
for r, c, value, kind in values:
|
||||
oracle = {'sheet': '数据', 'r': r, 'c': c}
|
||||
if kind == 'formula':
|
||||
formula, cached = value
|
||||
oracle['formula'] = formula
|
||||
oracle['value'] = None if writer == 'openpyxl' else cached
|
||||
if writer == 'openpyxl':
|
||||
sheet.cell(r + 1, c + 1, formula)
|
||||
else:
|
||||
sheet.write_formula(r, c, formula, None, cached)
|
||||
elif kind == 'date':
|
||||
oracle['source'] = {'iso': value.isoformat()}
|
||||
oracle['value'] = to_excel(value)
|
||||
if writer == 'openpyxl':
|
||||
sheet.cell(r + 1, c + 1, value).number_format = 'yyyy-mm-dd hh:mm:ss'
|
||||
else:
|
||||
sheet.write_datetime(r, c, value, book.add_format({'num_format': 'yyyy-mm-dd hh:mm:ss'}))
|
||||
elif kind == 'time':
|
||||
oracle['source'] = {'iso': str(value)}
|
||||
oracle['value'] = to_excel(value)
|
||||
if writer == 'openpyxl':
|
||||
sheet.cell(r + 1, c + 1, value).number_format = '[h]:mm:ss' if isinstance(value, timedelta) else 'hh:mm:ss'
|
||||
else:
|
||||
sheet.write_datetime(r, c, value, book.add_format({'num_format': '[h]:mm:ss'}))
|
||||
elif kind == 'error':
|
||||
oracle['value'] = value
|
||||
if writer == 'openpyxl':
|
||||
cell = sheet.cell(r + 1, c + 1, value)
|
||||
cell.data_type = 'e'
|
||||
else:
|
||||
# XlsxWriter exposes Excel error caches through its formula writer.
|
||||
sheet.write_formula(r, c, '=1/0', None, value)
|
||||
oracle['formula'] = '=1/0'
|
||||
else:
|
||||
oracle['value'] = value
|
||||
if writer == 'openpyxl':
|
||||
sheet.cell(r + 1, c + 1, value)
|
||||
elif isinstance(value, str):
|
||||
sheet.write_string(r, c, value)
|
||||
else:
|
||||
sheet.write(r, c, value)
|
||||
cells.append(oracle)
|
||||
|
||||
if writer == 'openpyxl':
|
||||
if 'chart' in active:
|
||||
chart = BarChart()
|
||||
chart.add_data(Reference(sheet, min_col=2, min_row=1, max_row=3), titles_from_data=True)
|
||||
sheet.add_chart(chart, 'H2')
|
||||
if 'image' in active:
|
||||
sheet.add_image(Image(io.BytesIO(PNG)), 'H18')
|
||||
if 'comment' in active:
|
||||
sheet['B2'].comment = Comment('生成的批注', 'fuzz')
|
||||
if 'conditional' in active:
|
||||
sheet.conditional_formatting.add('B2:B3', ColorScaleRule(start_type='min', start_color='FF0000', end_type='max', end_color='00FF00'))
|
||||
if 'conditional-databar' in active:
|
||||
sheet.conditional_formatting.add('B2:B3', DataBarRule(start_type='min', end_type='max', color='FF336699'))
|
||||
if 'conditional-iconset' in active:
|
||||
sheet.conditional_formatting.add('B2:B3', IconSetRule('3TrafficLights1', 'percent', [0, 33, 67]))
|
||||
if 'conditional-expression' in active:
|
||||
sheet.conditional_formatting.add('B2:B3', FormulaRule(formula=['B2>20'], font=Font(bold=True)))
|
||||
if 'table' in active:
|
||||
table = Table(displayName='DataTable', ref='A1:B3')
|
||||
table.tableStyleInfo = TableStyleInfo(name='TableStyleMedium2', showRowStripes=True)
|
||||
sheet.add_table(table)
|
||||
if 'validation' in active:
|
||||
validation = DataValidation(type='list', formula1='"A,B,C"')
|
||||
sheet.add_data_validation(validation)
|
||||
validation.add('F1:F3')
|
||||
if 'hyperlink' in active:
|
||||
sheet['A2'].hyperlink = 'https://example.com/?a=1&b=2'
|
||||
if 'merge' in active:
|
||||
sheet.merge_cells('H30:J30')
|
||||
if 'freeze' in active:
|
||||
sheet.freeze_panes = 'B2'
|
||||
if 'hidden' in active:
|
||||
sheet.row_dimensions[3].hidden = True
|
||||
sheet.column_dimensions['B'].hidden = True
|
||||
book.create_sheet('隐藏页').sheet_state = 'hidden'
|
||||
if 'header' in active:
|
||||
sheet.oddHeader.center.text = 'Fuzz &P'
|
||||
if 'styled' in active:
|
||||
sheet['B2'].font = Font(bold=True, color='FF112233')
|
||||
sheet['B2'].fill = PatternFill('solid', fgColor='FFFFFF00')
|
||||
book.save(output)
|
||||
else:
|
||||
if 'chart' in active:
|
||||
chart = book.add_chart({'type': 'column'})
|
||||
chart.add_series({'values': ['数据', 1, 1, 2, 1]})
|
||||
sheet.insert_chart('H2', chart)
|
||||
if 'image' in active:
|
||||
sheet.insert_image('H18', 'pixel.png', {'image_data': io.BytesIO(PNG)})
|
||||
if 'comment' in active:
|
||||
sheet.write_comment('B2', '生成的批注')
|
||||
if 'conditional' in active:
|
||||
sheet.conditional_format('B2:B3', {'type': '2_color_scale'})
|
||||
if 'conditional-databar' in active:
|
||||
sheet.conditional_format('B2:B3', {'type': 'data_bar', 'data_bar_2010': True})
|
||||
if 'conditional-iconset' in active:
|
||||
sheet.conditional_format('B2:B3', {'type': 'icon_set', 'icon_style': '3_traffic_lights'})
|
||||
if 'conditional-expression' in active:
|
||||
sheet.conditional_format('B2:B3', {'type': 'formula', 'criteria': '=B2>20', 'format': book.add_format({'bold': True})})
|
||||
if 'textbox' in active:
|
||||
sheet.insert_textbox('H32', 'Text box <chart/>')
|
||||
if 'table' in active:
|
||||
sheet.add_table('A1:B3', {'columns': [{'header': 'Item'}, {'header': 'Amount'}]})
|
||||
if 'validation' in active:
|
||||
sheet.data_validation('F1:F3', {'validate': 'list', 'source': ['A', 'B', 'C']})
|
||||
if 'hyperlink' in active:
|
||||
sheet.write_url('A2', 'https://example.com/?a=1&b=2', string='A')
|
||||
if 'merge' in active:
|
||||
sheet.merge_range('H30:J30', '', book.add_format())
|
||||
if 'freeze' in active:
|
||||
sheet.freeze_panes(1, 1)
|
||||
if 'hidden' in active:
|
||||
sheet.set_row(2, None, None, {'hidden': True})
|
||||
sheet.set_column('B:B', None, None, {'hidden': True})
|
||||
book.add_worksheet('隐藏页').hide()
|
||||
if 'header' in active:
|
||||
sheet.set_header('&CFuzz &P')
|
||||
if 'styled' in active:
|
||||
sheet.write('B2', 42, book.add_format({'bold': True, 'font_color': '#112233', 'bg_color': '#FFFF00'}))
|
||||
book.close()
|
||||
features = [key for feature, key in [('chart', 'charts'), ('image', 'images'), ('textbox', 'shapes')] if feature in active]
|
||||
if any(feature.startswith('conditional') for feature in active):
|
||||
features.append('conditionalFormatting')
|
||||
return fixed_zip(unpack(output.getvalue())), cells, features
|
||||
|
||||
|
||||
def mutate(data, mutation):
|
||||
"""Change encodings or OPC names without changing worksheet cell semantics."""
|
||||
files = unpack(data)
|
||||
if mutation == 'zip-store':
|
||||
return fixed_zip(files, zipfile.ZIP_STORED)
|
||||
if mutation == 'zip-reverse-comment':
|
||||
return fixed_zip(files, reverse=True, comment=b'Excel fuzz archive comment')
|
||||
if mutation == 'sheet-prefix':
|
||||
path = 'xl/worksheets/sheet1.xml'
|
||||
xml = files[path].decode()
|
||||
xml = xml.replace(f'xmlns="{MAIN_NS}"', f'xmlns:s="{MAIN_NS}"')
|
||||
files[path] = re.sub(r'<(/?)([A-Za-z_][\w.-]*)(?=[\s/>])', r'<\1s:\2', xml).encode()
|
||||
if mutation == 'sheet-filename':
|
||||
old, new = 'xl/worksheets/sheet1.xml', 'xl/worksheets/budget.xml'
|
||||
files[new] = files.pop(old)
|
||||
rel = 'xl/worksheets/_rels/sheet1.xml.rels'
|
||||
if rel in files:
|
||||
files['xl/worksheets/_rels/budget.xml.rels'] = files.pop(rel)
|
||||
for path in ['xl/_rels/workbook.xml.rels', '[Content_Types].xml']:
|
||||
files[path] = files[path].replace(b'worksheets/sheet1.xml', b'worksheets/budget.xml')
|
||||
if mutation == 'relationship-dot':
|
||||
path = 'xl/_rels/workbook.xml.rels'
|
||||
files[path] = files[path].replace(b'Target="worksheets/', b'Target="./worksheets/').replace(b'Target="/xl/worksheets/', b'Target="/xl/./worksheets/')
|
||||
if mutation == 'relationship-absolute':
|
||||
path = 'xl/_rels/workbook.xml.rels'
|
||||
files[path] = files[path].replace(b'Target="worksheets/', b'Target="/xl/worksheets/')
|
||||
if mutation in ['drawing-default', 'drawing-alternate']:
|
||||
for path in list(files):
|
||||
if re.fullmatch(r'xl/drawings/[^/]+\.xml', path):
|
||||
xml = files[path].decode()
|
||||
match = re.search(r'xmlns(?::([\w]+))?="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"', xml)
|
||||
if match:
|
||||
old = match.group(1)
|
||||
new = '' if mutation == 'drawing-default' else 'draw'
|
||||
xml = xml.replace(match.group(0), f'xmlns{":" + new if new else ""}="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"')
|
||||
if old:
|
||||
xml = re.sub(r'<(/?)' + re.escape(old) + ':', r'<\1' + (new + ':' if new else ''), xml)
|
||||
elif new:
|
||||
xml = re.sub(r'<(/?)([A-Za-z_][\w.-]*)(?=[\s/>])', r'<\1' + new + r':\2', xml)
|
||||
files[path] = xml.encode()
|
||||
return fixed_zip(files)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('output', type=Path)
|
||||
parser.add_argument('--seed', type=int, default=4863)
|
||||
parser.add_argument('--random-cases', type=int, default=300, help='Cases per independent writer')
|
||||
args = parser.parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=False)
|
||||
rng = random.Random(args.seed)
|
||||
manifest = {'seed': args.seed, 'writers': {'openpyxl': openpyxl.__version__, 'xlsxwriter': xlsxwriter.__version__}, 'cases': [], 'generatorErrors': []}
|
||||
base_values = [(0, 0, 'Item', 'value'), (0, 1, 'Amount', 'value'), (1, 0, 'A', 'value'),
|
||||
(1, 1, 42, 'value'), (2, 0, 'B', 'value'), (2, 1, 15, 'value')]
|
||||
directed = [([], base_values, 'plain')]
|
||||
directed += [([feature], base_values, feature) for feature in FEATURES]
|
||||
directed += [(['chart', feature], base_values, 'chart-' + feature) for feature in ['comment', 'image', 'conditional']]
|
||||
directed += [([], base_values + [(4, 0, error, 'error')], 'error-' + str(index)) for index, error in enumerate(ERRORS)]
|
||||
directed += [([], base_values + [(4, 0, ('=1+2', value), 'formula')], 'cache-' + str(index)) for index, value in enumerate([3, 0, False, True, '', '文本', '#DIV/0!'])]
|
||||
directed += [([epoch] if epoch else [], base_values + [(4, 0, value, kind)], f'temporal-{epoch}-{index}') for epoch in ['', 'epoch1904'] for index, (value, kind) in enumerate([
|
||||
(datetime(2024, 2, 29, 12, 34, 56), 'date'), (time(12, 34, 56), 'time'), (timedelta(days=2, hours=3), 'time')])]
|
||||
directed += [([], base_values + [(4, 0, value, 'value')], 'text-' + str(index)) for index, value in enumerate(['<pic/><chart/> & 字符', '😀\n多行\t数据', '_x0041_', '_x000D_', '00123'])]
|
||||
|
||||
def emit(data, cells, expected_features, writer, features, variant, valid=True):
|
||||
index = len(manifest['cases'])
|
||||
path = f'{index:05d}-{writer}-{variant}.xlsx'
|
||||
if valid:
|
||||
validate(data, cells)
|
||||
(args.output / path).write_bytes(data)
|
||||
manifest['cases'].append({'file': path, 'writer': writer, 'features': sorted(set(features)), 'variant': variant,
|
||||
'valid': valid, 'cells': cells, 'unsupportedFeatures': expected_features})
|
||||
|
||||
for writer in ['openpyxl', 'xlsxwriter']:
|
||||
writer_directed = directed + ([(['textbox'], base_values, 'textbox')] if writer == 'xlsxwriter' else [])
|
||||
for features, values, label in writer_directed:
|
||||
try:
|
||||
data, cells, expected = workbook_bytes(writer, features, values, args.seed)
|
||||
emit(data, cells, expected, writer, features, label)
|
||||
if label in ['plain', 'chart', 'image', 'comment', 'conditional', 'chart-comment']:
|
||||
for mutation in ['zip-store', 'zip-reverse-comment', 'sheet-prefix', 'sheet-filename', 'relationship-dot', 'relationship-absolute', 'drawing-default', 'drawing-alternate']:
|
||||
try:
|
||||
emit(mutate(data, mutation), cells, expected, writer, features, label + '--' + mutation)
|
||||
except Exception as error:
|
||||
manifest['generatorErrors'].append({'writer': writer, 'case': label, 'mutation': mutation, 'error': str(error)})
|
||||
except Exception as error:
|
||||
manifest['generatorErrors'].append({'writer': writer, 'case': label, 'error': str(error)})
|
||||
for index in range(args.random_cases):
|
||||
features = [feature for feature in FEATURES + (['textbox'] if writer == 'xlsxwriter' else []) if rng.random() < 0.3]
|
||||
values = copy.deepcopy(base_values)
|
||||
for row in range(5, rng.randint(6, 14)):
|
||||
value = rng.choice([rng.randint(-100000, 100000), rng.random() * 100, True, False, '中文😀', '<chart>literal</chart>', '00123'])
|
||||
values.append((row, rng.randrange(4), value, 'value'))
|
||||
values += [(3, 3, rng.choice(ERRORS), 'error')]
|
||||
try:
|
||||
data, cells, expected = workbook_bytes(writer, features, values, args.seed + index)
|
||||
mutation = rng.choice(['none', 'zip-store', 'zip-reverse-comment', 'drawing-default', 'drawing-alternate'])
|
||||
emit(mutate(data, mutation), cells, expected, writer, features, f'random-{index}-{mutation}')
|
||||
except Exception as error:
|
||||
manifest['generatorErrors'].append({'writer': writer, 'case': index, 'error': str(error)})
|
||||
|
||||
plain = next(case for case in manifest['cases'] if case['variant'] == 'plain')
|
||||
source = (args.output / plain['file']).read_bytes()
|
||||
for index in range(128):
|
||||
data = bytearray(source)
|
||||
if index < 32:
|
||||
data = data[:rng.randrange(1, len(data))]
|
||||
else:
|
||||
for _ in range(rng.randint(1, 5)):
|
||||
data[rng.randrange(len(data))] ^= rng.randint(1, 255)
|
||||
try:
|
||||
validate(bytes(data), plain['cells'])
|
||||
valid = True
|
||||
except Exception:
|
||||
valid = False
|
||||
emit(bytes(data), plain['cells'], [], 'mutated', [], f'bytes-{index}', valid=valid)
|
||||
|
||||
(args.output / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
print(json.dumps({'output': str(args.output), 'cases': len(manifest['cases']), 'valid': sum(case['valid'] for case in manifest['cases']), 'generatorErrors': len(manifest['generatorErrors'])}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,177 @@
|
||||
/** Explicit XLSX fuzz campaign with per-input worker deadlines and retained failure evidence. */
|
||||
import { readFile, writeFile, mkdir, copyFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { resolve, join, relative, isAbsolute } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { ExcelPreview } from '../../src/client/excel/model.ts'
|
||||
|
||||
interface OracleCell { sheet: string; r: number; c: number; value: string | number | boolean | null; formula?: string }
|
||||
interface Case {
|
||||
file: string
|
||||
writer: string
|
||||
features: string[]
|
||||
variant: string
|
||||
valid: boolean
|
||||
cells: OracleCell[]
|
||||
unsupportedFeatures: string[]
|
||||
}
|
||||
interface Manifest { seed: number; writers: Record<string, string>; cases: Case[]; generatorErrors: unknown[] }
|
||||
type Reply = ({ ok: true; preview: ExcelPreview } | { ok: false; causes: string[] }) & { inputUnchanged?: boolean }
|
||||
interface Outcome {
|
||||
file: string
|
||||
writer: string
|
||||
features: string[]
|
||||
variant: string
|
||||
valid: boolean
|
||||
status: string
|
||||
detail: string
|
||||
group: string
|
||||
elapsedMs: number
|
||||
opened: boolean
|
||||
sha256: string
|
||||
}
|
||||
|
||||
const { values, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: { only: { type: 'string' }, 'timeout-ms': { type: 'string', default: '10000' }, report: { type: 'string', default: 'results.json' }, 'baseline-ref': { type: 'string' } },
|
||||
})
|
||||
if (positionals.length !== 1) throw new Error('Usage: pnpm exec tsx packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts <corpus> [--only substring]')
|
||||
const directory = resolve(positionals[0]!)
|
||||
const deadlineMs = Number(values['timeout-ms'])
|
||||
if (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0) throw new Error('timeout-ms must be a positive integer')
|
||||
const manifest = JSON.parse(await readFile(join(directory, 'manifest.json'), 'utf8')) as Manifest
|
||||
const cases = manifest.cases.filter(test => values.only === undefined || test.file.includes(values.only))
|
||||
if (cases.length === 0) throw new Error('No cases selected')
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()
|
||||
const artifactRoot = fileURLToPath(new URL('../../.artifacts/', import.meta.url))
|
||||
let worker: Worker | undefined
|
||||
let baselineDirectory: string | undefined
|
||||
let source: string | undefined
|
||||
let parserRef = head
|
||||
if (values['baseline-ref'] !== undefined) {
|
||||
parserRef = execFileSync('git', ['rev-parse', '--verify', `${values['baseline-ref']}^{commit}`], { encoding: 'utf8' }).trim()
|
||||
const sourceRoot = new URL('../../src/client/excel/', import.meta.url).href
|
||||
const baseline = execFileSync('git', ['show', `${parserRef}:packages/client/ui-sidebar-documentpreview/src/client/excel/xlsx.ts`], { encoding: 'utf8' }).replaceAll("from './", `from '${sourceRoot}`)
|
||||
await mkdir(artifactRoot, { recursive: true })
|
||||
baselineDirectory = await mkdtemp(join(artifactRoot, 'excel-baseline-'))
|
||||
const path = join(baselineDirectory, 'xlsx.ts')
|
||||
await writeFile(path, baseline)
|
||||
source = pathToFileURL(path).href
|
||||
}
|
||||
|
||||
async function parse(bytes: Uint8Array<ArrayBuffer>): Promise<Reply> {
|
||||
// Source conversion is the subject; a dedicated worker can be terminated even during synchronous ZIP/XML work.
|
||||
worker ??= new Worker(new URL('./excel-worker.client.ts', import.meta.url), { execArgv: ['--import', 'tsx/esm'], workerData: { source }, resourceLimits: { maxOldGenerationSizeMb: 256 } })
|
||||
const current = worker
|
||||
return new Promise((resolveReply) => {
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer)
|
||||
current.off('message', message)
|
||||
current.off('error', error)
|
||||
current.off('exit', exit)
|
||||
}
|
||||
const message = (reply: Reply): void => { cleanup(); resolveReply(reply) }
|
||||
const error = (cause: Error): void => {
|
||||
cleanup()
|
||||
worker = undefined
|
||||
void current.terminate().then(() => { resolveReply({ ok: false, causes: [`WorkerError: ${cause.message}`] }) })
|
||||
}
|
||||
const exit = (code: number): void => {
|
||||
cleanup()
|
||||
worker = undefined
|
||||
resolveReply({ ok: false, causes: [`WorkerExit: ${code}`] })
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
worker = undefined
|
||||
void current.terminate().then(() => { resolveReply({ ok: false, causes: [`Timeout: ${deadlineMs}ms`] }) })
|
||||
}, deadlineMs)
|
||||
current.once('message', message)
|
||||
current.once('error', error)
|
||||
current.once('exit', exit)
|
||||
current.postMessage(bytes, [bytes.buffer])
|
||||
})
|
||||
}
|
||||
|
||||
function inspect(test: Case, reply: Reply): Pick<Outcome, 'status' | 'detail' | 'group'> {
|
||||
if (reply.inputUnchanged === false) return { status: 'mutated-source', detail: 'Conversion mutated the borrowed input bytes', group: 'mutated-source' }
|
||||
if (!reply.ok) {
|
||||
const detail = reply.causes.join(' <- ')
|
||||
const controlled = /^ExcelPreviewError: (invalid|tooLarge|timeout|encoding)$/.test(reply.causes[0] ?? '')
|
||||
const status = test.valid ? 'rejected-valid' : controlled ? 'pass' : 'unclassified-error'
|
||||
const variant = test.variant.includes('sheet-prefix') ? 'sheet-prefix' : test.variant.includes('sheet-filename') ? 'sheet-filename' : test.variant.includes('relationship-dot') ? 'relationship-dot' : 'content'
|
||||
return { status, detail, group: `${status}:${variant}:${reply.causes.at(-1)}` }
|
||||
}
|
||||
if (!test.valid) return { status: 'accepted-corrupt', detail: 'Independent reader or ZIP relationship validation rejected this input; permissive acceptance requires inspection.', group: 'accepted-corrupt' }
|
||||
for (const expected of test.cells) {
|
||||
const sheet = reply.preview.sheets.find(sheet => sheet.name === expected.sheet)
|
||||
const cell = sheet?.celldata?.find(cell => cell.r === expected.r && cell.c === expected.c)?.v
|
||||
const actual = cell?.v ?? null
|
||||
const same = typeof expected.value === 'number' && typeof actual === 'number'
|
||||
? Math.abs(actual - expected.value) <= Math.max(1, Math.abs(expected.value)) * 1e-12
|
||||
: actual === expected.value
|
||||
if (!same || (expected.formula !== undefined && expected.formula !== cell?.f)) {
|
||||
const detail = JSON.stringify({ expected, actual: cell ?? null })
|
||||
const category = expected.formula !== undefined ? 'formula-cache' : test.variant.startsWith('temporal') ? 'temporal' : 'value'
|
||||
return { status: 'value-mismatch', detail, group: `value-mismatch:${category}:${JSON.stringify(expected.value)}:${JSON.stringify(actual)}` }
|
||||
}
|
||||
}
|
||||
if (values['baseline-ref'] !== undefined) return { status: 'pass', detail: '', group: 'pass' }
|
||||
const actual = reply.preview.unsupportedFeatures
|
||||
if (new Set(actual).size !== actual.length || JSON.stringify(actual) !== JSON.stringify(test.unsupportedFeatures)) {
|
||||
const detail = JSON.stringify({ expected: test.unsupportedFeatures, actual })
|
||||
return { status: 'notice-mismatch', detail, group: `notice-mismatch:${detail}` }
|
||||
}
|
||||
return { status: 'pass', detail: '', group: 'pass' }
|
||||
}
|
||||
|
||||
const outcomes: Outcome[] = []
|
||||
try {
|
||||
for (const test of cases) {
|
||||
const bytes = new Uint8Array(await readFile(join(directory, test.file)))
|
||||
const sha256 = createHash('sha256').update(bytes).digest('hex')
|
||||
const start = performance.now()
|
||||
const reply = await parse(bytes)
|
||||
outcomes.push({
|
||||
file: test.file, writer: test.writer, features: test.features, variant: test.variant, valid: test.valid,
|
||||
...inspect(test, reply), elapsedMs: Math.round(performance.now() - start), opened: reply.ok, sha256,
|
||||
})
|
||||
if (outcomes.length % 100 === 0) console.log(JSON.stringify({ completed: outcomes.length, total: cases.length, findings: outcomes.filter(item => item.status !== 'pass').length }))
|
||||
}
|
||||
} finally {
|
||||
await worker?.terminate()
|
||||
if (baselineDirectory !== undefined) {
|
||||
const child = relative(artifactRoot, baselineDirectory)
|
||||
if (child === '' || child.startsWith('..') || isAbsolute(child)) throw new Error('Baseline cleanup escaped its artifact directory')
|
||||
await rm(baselineDirectory, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
const counts: Record<string, number> = {}
|
||||
const groups = new Map<string, Outcome[]>()
|
||||
for (const outcome of outcomes) {
|
||||
counts[outcome.status] = (counts[outcome.status] ?? 0) + 1
|
||||
if (outcome.status !== 'pass') groups.set(outcome.group, [...(groups.get(outcome.group) ?? []), outcome])
|
||||
}
|
||||
const representatives: { group: string; count: number; example: string; detail: string }[] = []
|
||||
await mkdir(join(directory, 'repros'), { recursive: true })
|
||||
for (const [group, members] of groups) {
|
||||
const sorted = [...members].sort((a, b) => a.features.length - b.features.length
|
||||
|| Number(a.variant.includes('random')) - Number(b.variant.includes('random')) || a.file.localeCompare(b.file))
|
||||
const first = sorted[0]!
|
||||
await copyFile(join(directory, first.file), join(directory, 'repros', first.file))
|
||||
representatives.push({ group, count: members.length, example: `repros/${first.file}`, detail: first.detail })
|
||||
}
|
||||
const report = {
|
||||
head, parserRef, seed: manifest.seed, writers: manifest.writers, selected: cases.length, counts,
|
||||
worktreeStatus: execFileSync('git', ['status', '--short'], { encoding: 'utf8' }).trim(),
|
||||
validOpened: outcomes.filter(outcome => outcome.valid && outcome.opened).length,
|
||||
validRejected: outcomes.filter(outcome => outcome.valid && !outcome.opened).length,
|
||||
generatorErrors: manifest.generatorErrors, representatives, outcomes,
|
||||
}
|
||||
await writeFile(join(directory, values.report), `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(JSON.stringify({ report: join(directory, values.report), head, selected: cases.length, counts, representatives }, null, 2))
|
||||
if (outcomes.some(outcome => outcome.status !== 'pass' && outcome.status !== 'accepted-corrupt')) process.exitCode = 1
|
||||
@@ -503,12 +503,16 @@ it('adds private deployment cookies to every Platform request without exposing t
|
||||
expect(JSON.stringify(await f.account.getState())).not.toContain('test_gate')
|
||||
await f.account.signOut()
|
||||
await expect.poll(f.logoutCount).toBe(1)
|
||||
expect(f.receivedHeaders.map(item => item.path)).toEqual([
|
||||
'/auth-api/v0/dsh/auth_init', '/auth-api/v0/dsh/auth_exchange',
|
||||
'/auth-api/v0/users/current', '/api/v0/users/get_user_summary', '/auth-api/v0/users/logout',
|
||||
])
|
||||
expect(f.receivedHeaders.every(item => item.cookie === 'test_gate=synthetic')).toBe(true)
|
||||
expect(f.receivedHeaders.slice(2).every(item => item.authorization === 'dsh_mock_test')).toBe(true)
|
||||
const headers = f.receivedHeaders
|
||||
const paths = headers.map(item => item.path)
|
||||
// The profile and wallet queries are independent requests, so their arrival order is scheduler-dependent.
|
||||
expect(paths.slice(0, 2)).toEqual(['/auth-api/v0/dsh/auth_init', '/auth-api/v0/dsh/auth_exchange'])
|
||||
expect(paths.slice(2, -1).sort()).toEqual(['/api/v0/users/get_user_summary', '/auth-api/v0/users/current'])
|
||||
expect(paths.at(-1)).toBe('/auth-api/v0/users/logout')
|
||||
expect(headers.every(item => item.cookie === 'test_gate=synthetic')).toBe(true)
|
||||
const grantedPaths = new Set(['/auth-api/v0/users/current', '/api/v0/users/get_user_summary', '/auth-api/v0/users/logout'])
|
||||
expect(headers.filter(item => grantedPaths.has(item.path ?? '')).every(item => item.authorization === 'dsh_mock_test')).toBe(true)
|
||||
expect(headers.filter(item => !grantedPaths.has(item.path ?? '')).every(item => item.authorization === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects reserved, duplicate and malformed deployment headers without disclosing values', () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface AccountView {
|
||||
|
||||
/** Platform account identifier supplied by the current-user endpoint. */
|
||||
export type AccountUserId = Branded<'AccountUserId'>
|
||||
/** Display identity and support UID; contacts retain Platform masking. */
|
||||
/** Display identity; contacts retain Platform masking. */
|
||||
export interface AccountProfile {
|
||||
readonly id: AccountUserId | null
|
||||
readonly name: string | null
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/shell/tool-pwsh-persistent/README.md
|
||||
README.md: f90442e7e9d8a361a690f66f41a58f1a4fcdd708
|
||||
README.zh.md: d634e8e42e186318a8733ee9ef2024e2840a857c
|
||||
README.md: cd78f224f9f85899cca1f578fd0316c6f4caab2f
|
||||
README.zh.md: 53b0b4cdd69ac94556f16621ccd3d9ca205cc6f4
|
||||
@@ -54,7 +54,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
||||
|
||||
### What the agent can rely on
|
||||
|
||||
Commands share one shell per agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude the private completion markers, the shell prompt, and the echoed input line. A non-zero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` (Windows forced termination reports exit 1 without a signal), then resets and tells the agent the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the terminal has already dropped that prefix, the result says so explicitly.
|
||||
Commands share one shell per agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude the private completion markers and the echoed input line. When the shell reads stdin again without having printed the completion marker — an interrupt, a replaced shell, or an interactive foreground child whose stdin wait the provider proves — the call returns the captured partial output, which can end with the backend's own prompt text. A non-zero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` (Windows forced termination reports exit 1 without a signal), then resets and tells the agent the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the terminal has already dropped that prefix, the result says so explicitly.
|
||||
|
||||
### What can go wrong
|
||||
|
||||
@@ -73,7 +73,7 @@ This section explains the design decisions behind the tool and points at the cod
|
||||
### Design philosophy
|
||||
|
||||
- **A deliberate twin of `dsh-tool-bash-persistent`.** The session registry, polling loop, and reset contract mirror the persistent bash tool by design ([pwsh persistent PTY Agent Note](../../../.agents/notes/archived/architecture/2026-08-11-pwsh-persistent-pty.md)).
|
||||
- **Prompt-function readiness.** The tool installs its own `prompt` function that prints a BEL-terminated OSC marker plus a printable prompt; the OSC marker carries the last exit code and the printable prompt settles every command, so a model redefinition of `prompt` degrades readiness to the silence tier.
|
||||
- **Backend-owned prompt readiness.** The shell's `prompt` function belongs to the backend: it prints a BEL-terminated OSC marker plus the controlled printable prompt, and that exact prompt text settles every command through the backend's fast path. The tool neither installs nor matches a prompt of its own, so only a model redefinition of `prompt` degrades readiness to the silence tier.
|
||||
- **PSReadLine echo stripped by anchoring.** PowerShell renders submitted input back into the stream; the marker-anchored extraction and a wrapper-source strip remove the echo, and a wrapper that wraps across the terminal width may leave a partial echo in partial-output results.
|
||||
- **Reset, never repair.** Any uncertain state — an explicit `exit`, a timeout, a send failure, an abort — closes the shell and starts the next call fresh.
|
||||
|
||||
@@ -81,12 +81,12 @@ This section explains the design decisions behind the tool and points at the cod
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Plugin entry: shell registry, prompt setup, command wrapping, scrollback polling, extraction and rendering |
|
||||
| [`src/index.ts`](src/index.ts) | Plugin entry: shell registry, command wrapping, scrollback polling, extraction and rendering |
|
||||
| — | No runtime invariant companion is published; the adapter's private owner-to-shell cache has no observable event or data relation. Lifecycle tests prove its cleanup without adding a public API solely for an invariant. |
|
||||
|
||||
### Command flow
|
||||
|
||||
A first command spawns the shell through `ctx.terminals.spawn`, installs the `prompt` override, and waits for readiness. Each command is wrapped into one physical line — `Write-Output` of the start marker, the body escaped with backtick escapes into a double-quoted string, and `Write-Output` of the end marker plus the exit status — so PSReadLine's echo of a wrapped line cannot fabricate completion. The tool polls the scrollback in 1,000-line pages until the end marker or a completed prompt appears, extracts the span, strips the echoed wrapper and prompts, and renders it with any status marker. A timeout aborts the deadline, captures the partial output, and resets the shell.
|
||||
A first command spawns the shell through `ctx.terminals.spawn`, whose pwsh startup installs the controlled `prompt` function and returns only once the shell reached readiness. Each command is wrapped into one physical line — `Write-Output` of the start marker, the body escaped with backtick escapes into a double-quoted string, and `Write-Output` of the end marker plus the exit status — so PSReadLine's echo of a wrapped line cannot fabricate completion. The tool polls the scrollback in 1,000-line pages until the end marker appears or the send settles as `stdin_read`, extracts the span, strips the echoed wrapper, and renders it with any status marker. A timeout aborts the deadline, captures the partial output, and resets the shell.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -128,7 +128,7 @@ Prefix-stable while the configured description and schema remain unchanged.
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Commands share one shell per Agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude private completion markers, the shell prompt, and the echoed input line (PSReadLine renders submitted input back into the stream; the marker-anchored extraction and the wrapper-source strip remove it). A nonzero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither (Windows forced termination reports exit 1 without a signal), then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the terminal has already dropped that prefix, the result says so explicitly. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset.
|
||||
Commands share one shell per Agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude private completion markers and the echoed input line (PSReadLine renders submitted input back into the stream; the marker-anchored extraction and the wrapper-source strip remove it). When the shell reads stdin again without having printed the completion marker — an interrupt, a replaced shell, or an interactive foreground child whose stdin wait the provider proves — the call returns the captured partial output, which can end with the backend's own prompt text. A nonzero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither (Windows forced termination reports exit 1 without a signal), then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the terminal has already dropped that prefix, the result says so explicitly. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -149,7 +149,7 @@ These limits define when the tool is a poor fit or needs special care. They are
|
||||
- **Input echo is unavoidable** — PowerShell's PSReadLine renders submitted input back into the terminal stream, and there is no `stty -echo` equivalent. The marker-anchored extraction excludes the echo in complete results; the wrapper-source strip covers fallback paths, but a wrapper that wraps across the terminal width may leave a partial echo in partial-output results, bounded by `maxOutputChars`.
|
||||
- **Raw ESC characters inside model commands are unsupported** — PSReadLine consumes them before execution. The wrapper escapes the control bytes it needs (`[char]27`-built OSC markers, backtick escapes for the body).
|
||||
- **A model redefinition of the `prompt` function removes the readiness marker** — the shell then settles on the silence tier instead of the marker fast path.
|
||||
- **There is no interactive stdin during a command** — a foreground command that reads input blocks until the command timeout, which resets the shell.
|
||||
- **There is no interactive stdin during a command** — where the provider proves a foreground stdin wait, a child that reads input returns early with partial output and leaves the session open, so that child can consume the next call's command until the call reaches `timeoutMs` and resets the shell. Everywhere else the original call itself runs to `timeoutMs`, which resets the shell.
|
||||
- **SIGTSTP/SIGHUP are unavailable on Windows** (backend-rejected); SIGINT is delivered as a console-wide Ctrl-C input write, which at a prompt cancels the pending line instead of signalling a process.
|
||||
- **Under the Windows ACL sandbox's read-only mode, pwsh starts in ConstrainedLanguage**, which may deny the bootstrap's `[Console]::` encoding pin and prompt marker. Commands can still settle through the printable prompt and silence tier, but non-ASCII output may follow the host code page.
|
||||
- **The BEL-terminated OSC marker remains a readiness signal only** — a BEL event channel to the model stays deferred, aligned with the current implementation.
|
||||
|
||||
@@ -54,7 +54,7 @@ kind: "package-reference"
|
||||
|
||||
### agent 可以依赖什么
|
||||
|
||||
命令共享每个 agent 一个 shell,因此 cwd、`$env:` 变量、函数与后台任务都会跨调用保留。结果排除私有完成标记、shell 提示符与回显的输入行。非零的包装命令追加 `[exit code: N]`——命令运行原生程序时给出确切原生退出码,PowerShell 终止错误则为 `1`。在报告该状态前就退出的 shell 改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]` 或 `[shell exited]`(Windows 强制终止报告 exit 1 且没有信号),然后重置并告诉 agent 下一次调用从全新状态开始。长输出保留最早的已保留前缀并附裁剪通知;若 terminal 已经丢弃该前缀,结果会明确说明。
|
||||
命令共享每个 agent 一个 shell,因此 cwd、`$env:` 变量、函数与后台任务都会跨调用保留。结果排除私有完成标记与回显的输入行。当 shell 再次读取 stdin 却没有打印完成标记时——一次中断、一个被替换的 shell,或提供方能证明其 stdin 等待的交互式前台子进程——调用返回已捕获的部分输出,其结尾可能是后端自己的提示符文本。非零的包装命令追加 `[exit code: N]`——命令运行原生程序时给出确切原生退出码,PowerShell 终止错误则为 `1`。在报告该状态前就退出的 shell 改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]` 或 `[shell exited]`(Windows 强制终止报告 exit 1 且没有信号),然后重置并告诉 agent 下一次调用从全新状态开始。长输出保留最早的已保留前缀并附裁剪通知;若 terminal 已经丢弃该前缀,结果会明确说明。
|
||||
|
||||
### 可能出什么问题
|
||||
|
||||
@@ -73,7 +73,7 @@ kind: "package-reference"
|
||||
### 设计理念
|
||||
|
||||
- **`dsh-tool-bash-persistent` 的刻意孪生。** 会话注册表、轮询循环与重置约定按设计镜像持久 bash 工具([pwsh 持久 PTY Agent Note](../../../.agents/notes/archived/architecture/2026-08-11-pwsh-persistent-pty.md))。
|
||||
- **prompt 函数就绪。** 工具安装自己的 `prompt` 函数,打印 BEL 结尾的 OSC 标记加可打印提示符;OSC 标记携带最后的退出码,可打印提示符让每条命令都能结算,因此模型重定义 `prompt` 会把就绪降级到静默层级。
|
||||
- **后端拥有提示符就绪。** shell 的 `prompt` 函数属于后端:它打印 BEL 结尾的 OSC 标记加受控的可打印提示符,该确切提示符文本让每条命令都经由后端快路径结算。工具既不安装也不匹配自己的提示符,因此只有模型重定义 `prompt` 才会把就绪降级到静默层级。
|
||||
- **PSReadLine 回显靠锚定剥离。** PowerShell 会把提交的输入渲染回流中;标记锚定提取与包装源码剥离移除回显,而跨终端宽度换行的包装可能在部分输出结果中留下部分回显。
|
||||
- **重置,而非修复。** 任何不确定状态——显式 `exit`、超时、发送失败、中止——都会关闭 shell 并让下一次调用从全新状态开始。
|
||||
|
||||
@@ -81,12 +81,12 @@ kind: "package-reference"
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | 插件入口:shell 注册表、prompt 设置、命令包装、scrollback 轮询、提取与渲染 |
|
||||
| [`src/index.ts`](src/index.ts) | 插件入口:shell 注册表、命令包装、scrollback 轮询、提取与渲染 |
|
||||
| — | 不发布运行时不变式伴生入口;适配器私有的 owner-to-shell 缓存不存在可观察的事件或数据关系。生命周期测试无需仅为不变式增加公共 API 即可验证其清理。 |
|
||||
|
||||
### 命令流程
|
||||
|
||||
首条命令通过 `ctx.terminals.spawn` 生成 shell,安装 `prompt` 覆盖,并等待就绪。随后每条命令都包装成一行物理文本——`Write-Output` 起始标记、用反引号转义进双引号字符串的命令体、`Write-Output` 结束标记加退出状态——因此 PSReadLine 对换行包装的回显无法伪造完成。工具以 1,000 行一页轮询 scrollback,直到出现结束标记或完成的提示符,提取区间、剥离回显的包装与提示符,并连同任何状态标记一起渲染。超时会中止截止时间、捕获部分输出并重置 shell。
|
||||
首条命令通过 `ctx.terminals.spawn` 生成 shell,其 pwsh 启动流程安装受控的 `prompt` 函数,并只在该 shell 达到就绪后返回。随后每条命令都包装成一行物理文本——`Write-Output` 起始标记、用反引号转义进双引号字符串的命令体、`Write-Output` 结束标记加退出状态——因此 PSReadLine 对换行包装的回显无法伪造完成。工具以 1,000 行一页轮询 scrollback,直到出现结束标记或该 send 以 `stdin_read` 结算,提取区间、剥离回显的包装,并连同任何状态标记一起渲染。超时会中止截止时间、捕获部分输出并重置 shell。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -128,7 +128,7 @@ kind: "package-reference"
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
命令共享每个 agent 一个 shell,因此 cwd、`$env:` 变量、函数与后台任务都会跨调用保留。结果排除私有完成标记、shell 提示词与回显的输入行(PSReadLine 会把提交的输入渲染回流中;标记锚定提取与包装源码剥离会移除它)。非零的包装命令追加 `[exit code: N]`——命令运行原生程序时给出确切原生退出码,PowerShell 终止错误则为 `1`。在报告该状态前就退出的 shell 改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]`,或后端两者都未提供时的 `[shell exited]`(Windows 强制终止报告 exit 1 且没有信号),然后重置并告诉模型下一次调用从全新状态开始。长输出保留最早的已保留前缀并附裁剪通知;若 terminal 已经丢弃该前缀,结果会明确说明。超时返回有界部分输出、关闭不确定的 shell 并报告重置。
|
||||
命令共享每个 agent 一个 shell,因此 cwd、`$env:` 变量、函数与后台任务都会跨调用保留。结果排除私有完成标记与回显的输入行(PSReadLine 会把提交的输入渲染回流中;标记锚定提取与包装源码剥离会移除它)。当 shell 再次读取 stdin 却没有打印完成标记时——一次中断、一个被替换的 shell,或提供方能证明其 stdin 等待的交互式前台子进程——调用返回已捕获的部分输出,其结尾可能是后端自己的提示符文本。非零的包装命令追加 `[exit code: N]`——命令运行原生程序时给出确切原生退出码,PowerShell 终止错误则为 `1`。在报告该状态前就退出的 shell 改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]`,或后端两者都未提供时的 `[shell exited]`(Windows 强制终止报告 exit 1 且没有信号),然后重置并告诉模型下一次调用从全新状态开始。长输出保留最早的已保留前缀并附裁剪通知;若 terminal 已经丢弃该前缀,结果会明确说明。超时返回有界部分输出、关闭不确定的 shell 并报告重置。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -149,7 +149,7 @@ kind: "package-reference"
|
||||
- **输入回显不可避免**——PowerShell 的 PSReadLine 会把提交的输入渲染回终端流,而且没有 `stty -echo` 等价物。标记锚定提取在完整结果中排除回显;包装源码剥离覆盖回退路径,但跨终端宽度换行的包装可能在部分输出结果中留下部分回显,受 `maxOutputChars` 设界。
|
||||
- **模型命令内的原始 ESC 字符不受支持**——PSReadLine 会在执行前消费它们。包装器会转义它需要的控制字节(`[char]27` 构造的 OSC 标记、正文的反引号转义)。
|
||||
- **模型重定义 `prompt` 函数会移除就绪标记**——shell 随后在静默层级而非标记快路径上结算。
|
||||
- **命令期间没有交互 stdin**——读取输入的前台命令会一直阻塞到命令超时,随后重置 shell。
|
||||
- **命令期间没有交互 stdin**——在提供方能证明前台 stdin 等待之处,读取输入的子进程会提前返回部分输出并保留会话,因此该子进程可能吞掉下一次调用的命令,直到那次调用到达 `timeoutMs` 并重置 shell。其他情况下原调用本身会运行到 `timeoutMs`,随后重置 shell。
|
||||
- **Windows 上 SIGTSTP/SIGHUP 不可用**(后端拒绝);SIGINT 以控制台级 Ctrl-C 输入写入投递,在提示词处会取消待处理行而不是向进程发信号。
|
||||
- **在 Windows ACL 沙箱的只读模式下,pwsh 以 ConstrainedLanguage 启动**,可能拒绝引导的 `[Console]::` 编码固定与 prompt 标记。命令仍可通过可打印提示词与静默层级结算,但非 ASCII 输出可能跟随宿主代码页。
|
||||
- **BEL 结尾的 OSC 标记目前只是就绪信号**——通向模型的 BEL 事件通道仍被推迟,与当前实现保持一致。
|
||||
|
||||
@@ -9,7 +9,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
|
||||
import type { TerminalReadResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
|
||||
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
@@ -17,7 +17,6 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with Select-String in order to find the line numbers of what you are looking for.</NOTE>'
|
||||
const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n'
|
||||
const SHELL_RESET_MESSAGE = 'The persistent pwsh shell was reset; the next pwsh call starts from the workspace with a fresh current directory and environment.'
|
||||
const SHELL_PROMPT = '__DSH_PERSISTENT_PWSH_PROMPT__ '
|
||||
const TIMEOUT_CODE = 'PERSISTENT_PWSH_TIMEOUT'
|
||||
// One page is enough to find a just-emitted completion marker; the full
|
||||
// scrollback is assembled only when a command settles or needs partial output.
|
||||
@@ -98,12 +97,8 @@ function wrapCommand(command: string, marker: CommandMarkers): string {
|
||||
return `Write-Output '${marker.start}'; $LASTEXITCODE = $null; $__s = 1; try { Invoke-Expression "${body}"; $__ok = $? } catch { $__ok = $false }; if ($null -ne $LASTEXITCODE) { $__s = [int]$LASTEXITCODE } else { $__s = if ($__ok) { 0 } else { 1 } }; Write-Output ('${marker.end}' + $__s)`
|
||||
}
|
||||
|
||||
function stripPrompt(text: string): string {
|
||||
let result = text.replace(/\r?\n$/, '')
|
||||
while (result.endsWith(SHELL_PROMPT)) {
|
||||
result = result.slice(0, -SHELL_PROMPT.length)
|
||||
}
|
||||
return result.endsWith('\n') ? result.slice(0, -1) : result
|
||||
function trimTrailingNewline(text: string): string {
|
||||
return text.replace(/\r?\n$/, '')
|
||||
}
|
||||
|
||||
function commandOutput(
|
||||
@@ -130,12 +125,6 @@ function commandOutput(
|
||||
}
|
||||
}
|
||||
|
||||
function promptCompleted(result: TerminalSendResult): boolean {
|
||||
return result.viewport.endsWith(SHELL_PROMPT)
|
||||
|| result.viewport.endsWith(`${SHELL_PROMPT}\r\n`)
|
||||
|| result.viewport.endsWith(`${SHELL_PROMPT}\n`)
|
||||
}
|
||||
|
||||
function partialOutput(
|
||||
snapshot: RetainedOutput,
|
||||
marker: CommandMarkers,
|
||||
@@ -146,7 +135,7 @@ function partialOutput(
|
||||
const startMarker = snapshot.text.lastIndexOf(marker.start)
|
||||
if (startMarker >= 0) {
|
||||
return {
|
||||
text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
|
||||
text: trimTrailingNewline(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
|
||||
incomplete: false,
|
||||
}
|
||||
}
|
||||
@@ -157,7 +146,7 @@ function partialOutput(
|
||||
const fallbackEnd = afterStart.lastIndexOf(marker.end)
|
||||
const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)
|
||||
return {
|
||||
text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '').replaceAll(wrapper, '')),
|
||||
text: trimTrailingNewline(beforeEnd.replaceAll(wrapper, '')),
|
||||
incomplete: fallbackTruncated || fallbackStart < 0,
|
||||
}
|
||||
}
|
||||
@@ -252,15 +241,6 @@ async function respondToSessionExit(
|
||||
].filter(part => part.length > 0).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* The pwsh prompt function that overrides the backend bootstrap value with
|
||||
* this tool's own prompt. `[char]27`/`[char]7` build the OSC bytes at runtime
|
||||
* because raw ESC characters in submitted input are unreliable under
|
||||
* PSReadLine.
|
||||
*/
|
||||
const PWSH_PROMPT_SETUP =
|
||||
"function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + SHELL_PROMPT + "' }"
|
||||
|
||||
function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells {
|
||||
const pending = new WeakMap<Agent, Promise<TerminalSessionId>>()
|
||||
const live = new Map<Agent, TerminalSessionId>()
|
||||
@@ -307,15 +287,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
|
||||
live.delete(owner)
|
||||
}, 'tool-pwsh-persistent owner cache cleanup')
|
||||
}
|
||||
const setup = ctx.terminals.startSend(owner, spawned.sessionId, {
|
||||
text: PWSH_PROMPT_SETUP,
|
||||
submit: true,
|
||||
signal: combinedSignal,
|
||||
})
|
||||
const result = await setup.done
|
||||
if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') {
|
||||
throw new Error('persistent pwsh shell did not accept initialization')
|
||||
}
|
||||
return spawned.sessionId
|
||||
} catch (error: unknown) {
|
||||
await reset(owner, 'persistent pwsh initialization failed')
|
||||
@@ -414,7 +385,11 @@ async function executeCommand(
|
||||
ctx, shells, owner, id, result.sessionStatus, marker, wrapped, fallback, fallbackTruncated, config,
|
||||
)
|
||||
}
|
||||
if (promptCompleted(result)) {
|
||||
// The shell reads stdin again (its prompt, or a foreground child's own
|
||||
// read) without having printed the end marker — an interrupt, a replaced
|
||||
// shell, or an interactive child. Return what was captured instead of
|
||||
// spinning until the command deadline.
|
||||
if (result.waitReason === 'stdin_read') {
|
||||
const snapshot = retainedScrollback(ctx, owner, id, latest)
|
||||
return renderCaptured(
|
||||
partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
@@ -13,6 +13,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import TerminalSessionService from '@deepseek-ai/dsh-terminal'
|
||||
import type { TerminalWaitReason } from '@deepseek-ai/dsh-terminal'
|
||||
import * as TerminalBash from '@deepseek-ai/dsh-terminal-bash'
|
||||
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
@@ -96,7 +97,9 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp
|
||||
' shellDialect: pwsh',
|
||||
' pollIntervalMs: 10',
|
||||
' exactProbeAfterMs: 20',
|
||||
' idleSilenceMs: 300',
|
||||
// The silence tier keeps its product default; the case body records each
|
||||
// send's wait reason, which pins the controlled-prompt fast path directly
|
||||
// instead of relying on how long silence would take to settle.
|
||||
' handoffGraceMs: 300',
|
||||
' scrollbackLines: 20000',
|
||||
// The first call pays the full pwsh cold-start latency (spawn + .NET +
|
||||
@@ -141,6 +144,22 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp
|
||||
await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
|
||||
await context.loader.await()
|
||||
|
||||
const terminals = context.terminals
|
||||
const startSend = terminals.startSend.bind(terminals)
|
||||
// A send that lost the controlled-prompt fast path settles as inferred_idle
|
||||
// after the silence tier, so recording why every send settled detects that
|
||||
// regression immediately instead of through accumulated wall-clock.
|
||||
const settleReasons: TerminalWaitReason[] = []
|
||||
vi.spyOn(terminals, 'startSend').mockImplementation((owner, id, request) => {
|
||||
const operation = startSend(owner, id, request)
|
||||
void operation.done.then(
|
||||
(settled) => { settleReasons.push(settled.waitReason) },
|
||||
// A rejected send is the tool's error path, not a settle reason.
|
||||
() => {},
|
||||
)
|
||||
return operation
|
||||
})
|
||||
|
||||
const owner = await agent(context, root)
|
||||
const signal = new AbortController().signal
|
||||
const execute = (id: string, command: string) => context!.tools.execute({
|
||||
@@ -178,5 +197,10 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp
|
||||
const exited = text(await execute('exit', 'exit'))
|
||||
expect(exited).toContain('next pwsh call starts from the workspace')
|
||||
expect(text(await execute('after-exit', 'Write-Output "$PWD"'))).toBe(root)
|
||||
|
||||
// Six commands settle on the controlled prompt; no send may fall back to the
|
||||
// silence tier, which is the 3.5 s-per-call degradation this suite pins.
|
||||
expect(settleReasons.filter(reason => reason === 'stdin_read').length).toBeGreaterThanOrEqual(6)
|
||||
expect(settleReasons).not.toContain('inferred_idle')
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -94,8 +94,6 @@ type StubMode =
|
||||
| 'torn-status'
|
||||
| 'finish-torn-status'
|
||||
| 'end-only'
|
||||
| 'init-exit'
|
||||
| 'init-timeout'
|
||||
| 'spawn-error'
|
||||
| 'send-error'
|
||||
| 'prompt-after-idle'
|
||||
@@ -110,7 +108,7 @@ const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/
|
||||
const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/
|
||||
|
||||
class StubTerminalSession implements TerminalBackendSession {
|
||||
readonly motd = '__DSH_PERSISTENT_PWSH_PROMPT__ '
|
||||
readonly motd = 'stub> '
|
||||
readonly pid = 123
|
||||
statusValue: TerminalSessionStatus = { kind: 'running' }
|
||||
scrollback = this.motd
|
||||
@@ -127,16 +125,6 @@ class StubTerminalSession implements TerminalBackendSession {
|
||||
|
||||
startSend(request: TerminalSendRequest): TerminalSendOperation {
|
||||
this.sends += 1
|
||||
if (request.text.startsWith('function prompt')) {
|
||||
if (this.mode === 'init-exit') {
|
||||
this.statusValue = { kind: 'exited', exitCode: 1, signal: null }
|
||||
return this.operation(Promise.resolve(this.result('', 'session_exit')))
|
||||
}
|
||||
if (this.mode === 'init-timeout') {
|
||||
return this.operation(Promise.resolve(this.result('', 'timeout')))
|
||||
}
|
||||
return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')))
|
||||
}
|
||||
if (this.mode === 'send-error') throw new Error('stub send failed')
|
||||
if (this.throwOnSend) throw new Error('PTY session has exited')
|
||||
if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') {
|
||||
@@ -347,7 +335,7 @@ describe('tool-pwsh-persistent', () => {
|
||||
expect(text(await call(ctx, owner, 'Write-Output one'))).toBe('hello from stub')
|
||||
expect(text(await call(ctx, owner, 'Write-Output two'))).toBe('hello from stub')
|
||||
expect(stub.sessions).toHaveLength(1)
|
||||
expect(stub.sessions[0]?.sends).toBe(3)
|
||||
expect(stub.sessions[0]?.sends).toBe(2)
|
||||
|
||||
const ownerWithoutCwd = await agent(ctx, undefined)
|
||||
expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub')
|
||||
@@ -369,7 +357,7 @@ describe('tool-pwsh-persistent', () => {
|
||||
expect(result).not.toContain('Invoke-Expression')
|
||||
})
|
||||
|
||||
it('preserves command output that equals the private shell prompt', async () => {
|
||||
it("preserves command output that equals the backend's prompt text", async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub' })
|
||||
await call(ctx, owner, 'warm up')
|
||||
const session = stub.sessions[0]!
|
||||
@@ -393,7 +381,7 @@ describe('tool-pwsh-persistent', () => {
|
||||
expect(stub.sessions).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => {
|
||||
it('handles inferred idle, stdin_read fallback, shell exit, clipping, and cleanup', async () => {
|
||||
const { ctx, owner, stub, fiber } = await setup({
|
||||
backendType: 'stub',
|
||||
maxOutputChars: 10,
|
||||
@@ -406,18 +394,22 @@ describe('tool-pwsh-persistent', () => {
|
||||
|
||||
session.mode = 'incremental-fallback'
|
||||
session.scrollback = ''
|
||||
expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment')
|
||||
expect(text(await call(ctx, owner, 'incremental fallback'))).toContain('increment')
|
||||
|
||||
session.mode = 'prompt-only'
|
||||
const promptFallback = text(await call(ctx, owner, 'bad {'))
|
||||
expect(promptFallback).toContain('pwsh: synt')
|
||||
expect(promptFallback).not.toContain('DSH_PERSISTENT_PWSH_PROMPT')
|
||||
|
||||
session.mode = 'prompt-crlf'
|
||||
session.scrollback = ''
|
||||
const crlfPromptFallback = text(await call(ctx, owner, 'bad {'))
|
||||
expect(crlfPromptFallback).toContain('pwsh: synt')
|
||||
expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_PWSH_PROMPT')
|
||||
|
||||
// The stdin_read fallback returns captured output without resetting the
|
||||
// shell, so later calls keep the same session; the README names the
|
||||
// interactive-child consequence of that retention.
|
||||
expect(session.closed).toEqual([])
|
||||
expect(stub.sessions).toHaveLength(1)
|
||||
|
||||
session.mode = 'end-only'
|
||||
session.scrollback = ''
|
||||
@@ -503,7 +495,7 @@ describe('tool-pwsh-persistent', () => {
|
||||
expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub')
|
||||
})
|
||||
|
||||
it('sanitizes a prompt fallback reached after multiple polling rounds', async () => {
|
||||
it('returns a stdin_read fallback reached after multiple polling rounds', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
|
||||
await call(ctx, owner, 'warm up')
|
||||
const session = stub.sessions[0]!
|
||||
@@ -512,7 +504,8 @@ describe('tool-pwsh-persistent', () => {
|
||||
const result = text(await call(ctx, owner, 'bad {'))
|
||||
expect(result).toContain('partial syntax output')
|
||||
expect(result).toContain('pwsh: syntax error')
|
||||
expect(result).not.toContain('DSH_PERSISTENT_PWSH_PROMPT')
|
||||
// The backend owns the prompt text, so the fallback retains it verbatim.
|
||||
expect(result.endsWith('stub> ')).toBe(true)
|
||||
expect(result).not.toContain('DSH_PERSISTENT_PWSH_START')
|
||||
})
|
||||
|
||||
@@ -547,7 +540,7 @@ describe('tool-pwsh-persistent', () => {
|
||||
const cancelled = call(ctx, owner, 'hang', controller.signal)
|
||||
const queued = call(ctx, owner, 'after cancellation')
|
||||
try {
|
||||
await expect.poll(() => stub.sessions[0]!.sends).toBe(3)
|
||||
await expect.poll(() => stub.sessions[0]!.sends).toBe(2)
|
||||
controller.abort({ kind: 'user' })
|
||||
|
||||
const result = await cancelled
|
||||
@@ -578,14 +571,14 @@ describe('tool-pwsh-persistent', () => {
|
||||
const running = call(ctx, owner, 'hang', runningController.signal)
|
||||
const queued = call(ctx, owner, 'never sent', queuedController.signal)
|
||||
try {
|
||||
await expect.poll(() => session.sends).toBe(3)
|
||||
await expect.poll(() => session.sends).toBe(2)
|
||||
await expect.poll(() => execute.mock.calls.length).toBe(2)
|
||||
queuedController.abort({ kind: 'user' })
|
||||
runningController.abort({ kind: 'user' })
|
||||
const result = await queued
|
||||
expect(text(result)).toBe('Error: tool call aborted')
|
||||
expect(result.error?.info).toEqual({ name: 'AbortError', code: 'ABORTED' })
|
||||
expect(session.sends).toBe(3)
|
||||
expect(session.sends).toBe(2)
|
||||
expect(stub.sessions).toHaveLength(1)
|
||||
expect(session.closed).toContain('persistent pwsh command aborted')
|
||||
} finally {
|
||||
@@ -670,14 +663,14 @@ describe('tool-pwsh-persistent', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['initialization', 'command', 'cleanup'] as const)('preserves a distinct %s failure during cancellation', async (phase) => {
|
||||
it.each(['command', 'cleanup'] as const)('preserves a distinct %s failure during cancellation', async (phase) => {
|
||||
const { ctx, owner } = await setup()
|
||||
if (phase === 'command') await call(ctx, owner, 'warm up')
|
||||
await call(ctx, owner, 'warm up')
|
||||
const controller = new AbortController()
|
||||
const failure = new Error(`${phase} failed`)
|
||||
vi.spyOn(ctx.terminals, 'startSend').mockImplementationOnce(() => {
|
||||
controller.abort({ kind: 'user' })
|
||||
throw phase === 'cleanup' ? new Error('initialization failed') : failure
|
||||
throw phase === 'cleanup' ? new Error('send failed') : failure
|
||||
})
|
||||
if (phase === 'cleanup') vi.spyOn(ctx.terminals, 'kill').mockRejectedValueOnce(failure)
|
||||
const result = await call(ctx, owner, 'fails', controller.signal)
|
||||
@@ -703,15 +696,6 @@ describe('tool-pwsh-persistent', () => {
|
||||
expect(stub.sessions).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['init-exit', 'init-timeout'] as const)(
|
||||
'fails initialization and closes the unusable shell for %s',
|
||||
async (mode) => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode)
|
||||
expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
|
||||
expect(stub.sessions[0]?.closed).toContain('persistent pwsh initialization failed')
|
||||
},
|
||||
)
|
||||
|
||||
it('clears a failed spawn without trying to close an unpublished shell', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub' }, 'spawn-error')
|
||||
expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
|
||||
|
||||
@@ -0,0 +1,806 @@
|
||||
diff --git a/dist/exceljs.js b/dist/exceljs.js
|
||||
index ab2c9d6e576d8069d77d787b863dfe09c7659890..99213b8606d02b8948ce6ce44a5a7dcfc203d854 100644
|
||||
--- a/dist/exceljs.js
|
||||
+++ b/dist/exceljs.js
|
||||
@@ -4636,24 +4636,37 @@ module.exports = async function* (iterable) {
|
||||
if (iterable.pipe && !iterable[Symbol.asyncIterator]) {
|
||||
iterable = iterable.pipe(new PassThrough());
|
||||
}
|
||||
- const saxesParser = new SaxesParser();
|
||||
+ const saxesParser = new SaxesParser({xmlns: true});
|
||||
+ // Xforms use canonical names; OOXML producers may choose different prefixes.
|
||||
+ const prefixes = new Map([
|
||||
+ ['http://schemas.openxmlformats.org/spreadsheetml/2006/main', ''],
|
||||
+ ['http://purl.oclc.org/ooxml/spreadsheetml/main', ''],
|
||||
+ ['http://schemas.openxmlformats.org/package/2006/relationships', ''],
|
||||
+ ['http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'r'],
|
||||
+ ['http://purl.oclc.org/ooxml/officeDocument/relationships', 'r'],
|
||||
+ ['urn:schemas-microsoft-com:vml', 'v'],
|
||||
+ ['urn:schemas-microsoft-com:office:office', 'o'],
|
||||
+ ['urn:schemas-microsoft-com:office:excel', 'x'],
|
||||
+ ]);
|
||||
+ const canonicalName = node => {
|
||||
+ const prefix = prefixes.get(node.uri);
|
||||
+ return prefix === undefined ? node.name : prefix ? `${prefix}:${node.local}` : node.local;
|
||||
+ };
|
||||
let error;
|
||||
saxesParser.on('error', err => {
|
||||
error = err;
|
||||
});
|
||||
let events = [];
|
||||
- saxesParser.on('opentag', value => events.push({
|
||||
- eventType: 'opentag',
|
||||
- value
|
||||
- }));
|
||||
- saxesParser.on('text', value => events.push({
|
||||
- eventType: 'text',
|
||||
- value
|
||||
- }));
|
||||
- saxesParser.on('closetag', value => events.push({
|
||||
- eventType: 'closetag',
|
||||
- value
|
||||
- }));
|
||||
+ saxesParser.on('opentag', node => {
|
||||
+ const attributes = Object.create(null);
|
||||
+ Object.values(node.attributes).forEach(attribute => {
|
||||
+ attributes[canonicalName(attribute)] = attribute.value;
|
||||
+ });
|
||||
+ events.push({eventType: 'opentag', value: {...node, name: canonicalName(node), attributes}});
|
||||
+ });
|
||||
+ saxesParser.on('text', value => events.push({eventType: 'text', value}));
|
||||
+ saxesParser.on('cdata', value => events.push({eventType: 'text', value}));
|
||||
+ saxesParser.on('closetag', node => events.push({eventType: 'closetag', value: {...node, name: canonicalName(node)}}));
|
||||
for await (const chunk of iterable) {
|
||||
saxesParser.write(bufferToString(chunk));
|
||||
// saxesParser.write and saxesParser.on() are synchronous,
|
||||
@@ -6477,12 +6490,11 @@ class WorkbookXform extends BaseXform {
|
||||
let index = 0;
|
||||
(model.sheets || []).forEach(sheet => {
|
||||
const rel = rels[sheet.rId];
|
||||
- if (!rel) {
|
||||
- return;
|
||||
+ if (!rel) throw new Error(`Missing worksheet relationship: ${sheet.rId}`);
|
||||
+ worksheet = model.worksheetHash[rel.Target];
|
||||
+ if (!worksheet && rel.Type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet') {
|
||||
+ throw new Error(`Missing worksheet: ${rel.Target}`);
|
||||
}
|
||||
- // if rel.Target start with `[space]/xl/` or `/xl/` , then it will be replaced with `''` and spliced behind `xl/`,
|
||||
- // otherwise it will be spliced directly behind `xl/`. i.g.
|
||||
- worksheet = model.worksheetHash[`xl/${rel.Target.replace(/^(\s|\/xl\/)+/, '')}`];
|
||||
// If there are "chartsheets" in the file, rel.Target will
|
||||
// come out as chartsheets/sheet1.xml or similar here, and
|
||||
// that won't be in model.worksheetHash.
|
||||
@@ -6635,6 +6647,11 @@ utils.inherits(CommentXform, BaseXform, {
|
||||
...node.attributes
|
||||
};
|
||||
return true;
|
||||
+ case 't':
|
||||
+ this.plainText = true;
|
||||
+ this.parser = this.richTextXform.textXform;
|
||||
+ this.parser.parseOpen(node);
|
||||
+ return true;
|
||||
case 'r':
|
||||
this.parser = this.richTextXform;
|
||||
this.parser.parseOpen(node);
|
||||
@@ -6649,6 +6666,12 @@ utils.inherits(CommentXform, BaseXform, {
|
||||
}
|
||||
},
|
||||
parseClose(name) {
|
||||
+ if (this.plainText && name === 't') {
|
||||
+ this.model.note.texts.push({text: this.parser.model});
|
||||
+ this.parser = undefined;
|
||||
+ this.plainText = false;
|
||||
+ return true;
|
||||
+ }
|
||||
switch (name) {
|
||||
case 'comment':
|
||||
return false;
|
||||
@@ -12386,17 +12409,17 @@ class WorkSheetXform extends BaseXform {
|
||||
// options.merges.reconcile(model.mergeCells, model.rows);
|
||||
const rels = (model.relationships || []).reduce((h, rel) => {
|
||||
h[rel.Id] = rel;
|
||||
- if (rel.Type === RelType.Comments) {
|
||||
- model.comments = options.comments[rel.Target].comments;
|
||||
- }
|
||||
- if (rel.Type === RelType.VmlDrawing && model.comments && model.comments.length) {
|
||||
- const vmlComment = options.vmlDrawings[rel.Target].comments;
|
||||
- model.comments.forEach((comment, index) => {
|
||||
- comment.note = Object.assign({}, comment.note, vmlComment[index]);
|
||||
- });
|
||||
- }
|
||||
return h;
|
||||
}, {});
|
||||
+ const commentRel = Object.values(rels).find(rel => rel.Type === RelType.Comments);
|
||||
+ if (commentRel) model.comments = options.comments[commentRel.Target].comments;
|
||||
+ const vmlRel = Object.values(rels).find(rel => rel.Type === RelType.VmlDrawing);
|
||||
+ if (vmlRel && model.comments && model.comments.length) {
|
||||
+ const vmlComments = options.vmlDrawings[vmlRel.Target].comments;
|
||||
+ model.comments.forEach((comment, index) => {
|
||||
+ comment.note = Object.assign({}, comment.note, vmlComments[index]);
|
||||
+ });
|
||||
+ }
|
||||
options.commentsMap = (model.comments || []).reduce((h, comment) => {
|
||||
if (comment.ref) {
|
||||
h[comment.ref] = comment;
|
||||
@@ -15491,9 +15514,7 @@ const ZipStream = require('../utils/zip-stream');
|
||||
const StreamBuf = require('../utils/stream-buf');
|
||||
const utils = require('../utils/utils');
|
||||
const XmlStream = require('../utils/xml-stream');
|
||||
-const {
|
||||
- bufferToString
|
||||
-} = require('../utils/browser-buffer-decode');
|
||||
+
|
||||
const StylesXform = require('./xform/style/styles-xform');
|
||||
const CoreXform = require('./xform/core/core-xform');
|
||||
const SharedStringsXform = require('./xform/strings/shared-strings-xform');
|
||||
@@ -15621,6 +15642,31 @@ class XLSX {
|
||||
delete model.drawingRels;
|
||||
delete model.vmlDrawings;
|
||||
}
|
||||
+ // XML processors must accept UTF-8 and both byte orders of UTF-16.
|
||||
+ async _readXmlEntry(entry) {
|
||||
+ const bytes = await entry.async('uint8array');
|
||||
+ const encoding = (bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0x3c && bytes[1] === 0)
|
||||
+ ? 'utf-16le'
|
||||
+ : (bytes[0] === 0xfe && bytes[1] === 0xff) || (bytes[0] === 0 && bytes[1] === 0x3c)
|
||||
+ ? 'utf-16be' : 'utf-8';
|
||||
+ return new TextDecoder(encoding, {fatal: true}).decode(bytes);
|
||||
+ }
|
||||
+
|
||||
+ // Resolve an internal OPC target against its owning part, without filesystem access.
|
||||
+ _resolvePart(owner, target) {
|
||||
+ const parts = target.startsWith('/') ? [] : owner.split('/').slice(0, -1);
|
||||
+ for (const segment of target.split('/')) {
|
||||
+ if (!segment || segment === '.') continue;
|
||||
+ if (segment === '..') {
|
||||
+ if (!parts.length) throw new Error(`Relationship escapes package: ${target}`);
|
||||
+ parts.pop();
|
||||
+ } else {
|
||||
+ parts.push(segment);
|
||||
+ }
|
||||
+ }
|
||||
+ return parts.join('/');
|
||||
+ }
|
||||
+
|
||||
async _processWorksheetEntry(stream, model, sheetNo, options, path) {
|
||||
const xform = new WorksheetXform(options);
|
||||
const worksheet = await xform.parseStream(stream);
|
||||
@@ -15631,18 +15677,14 @@ class XLSX {
|
||||
async _processCommentEntry(stream, model, name) {
|
||||
const xform = new CommentsXform();
|
||||
const comments = await xform.parseStream(stream);
|
||||
- model.comments[`../${name}.xml`] = comments;
|
||||
+ model.comments[name] = comments;
|
||||
}
|
||||
async _processTableEntry(stream, model, name) {
|
||||
const xform = new TableXform();
|
||||
const table = await xform.parseStream(stream);
|
||||
- model.tables[`../tables/${name}.xml`] = table;
|
||||
- }
|
||||
- async _processWorksheetRelsEntry(stream, model, sheetNo) {
|
||||
- const xform = new RelationshipsXform();
|
||||
- const relationships = await xform.parseStream(stream);
|
||||
- model.worksheetRels[sheetNo] = relationships;
|
||||
+ model.tables[name] = table;
|
||||
}
|
||||
+
|
||||
async _processMediaEntry(entry, model, filename) {
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
// if we can't determine extension, ignore it
|
||||
@@ -15683,7 +15725,7 @@ class XLSX {
|
||||
async _processVmlDrawingEntry(entry, model, name) {
|
||||
const xform = new VmlNotesXform();
|
||||
const vmlDrawing = await xform.parseStream(entry);
|
||||
- model.vmlDrawings[`../drawings/${name}.vml`] = vmlDrawing;
|
||||
+ model.vmlDrawings[name] = vmlDrawing;
|
||||
}
|
||||
async _processThemeEntry(entry, model, name) {
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -15727,7 +15769,7 @@ class XLSX {
|
||||
const model = {
|
||||
worksheets: [],
|
||||
worksheetHash: {},
|
||||
- worksheetRels: [],
|
||||
+ worksheetRels: Object.create(null),
|
||||
themes: {},
|
||||
media: [],
|
||||
mediaIndex: {},
|
||||
@@ -15738,6 +15780,49 @@ class XLSX {
|
||||
vmlDrawings: {}
|
||||
};
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
+ const partKey = path => path.replace(/[A-Z]/g, character => character.toLowerCase());
|
||||
+ const partNames = new Map();
|
||||
+ for (const entry of Object.values(zip.files)) {
|
||||
+ if (entry.dir) continue;
|
||||
+ const key = partKey(entry.name);
|
||||
+ if (partNames.has(key)) throw new Error(`Ambiguous workbook part: ${entry.name}`);
|
||||
+ partNames.set(key, entry.name);
|
||||
+ }
|
||||
+ const partTypes = new Map();
|
||||
+ const relationships = new Map();
|
||||
+ const resolvedTypes = new Set([
|
||||
+ XLSX.RelType.OfficeDocument, XLSX.RelType.Styles, XLSX.RelType.SharedStrings,
|
||||
+ XLSX.RelType.Worksheet, XLSX.RelType.Comments, XLSX.RelType.Table, XLSX.RelType.VmlDrawing,
|
||||
+ ]);
|
||||
+ // Relationship order and ZIP entry order do not determine part identity.
|
||||
+ for (const entry of Object.values(zip.files)) {
|
||||
+ const match = entry.name.match(/^(.*\/)?_rels\/([^/]+)\.rels$/i);
|
||||
+ if (entry.dir || (!match && partKey(entry.name) !== '_rels/.rels')) continue;
|
||||
+ const owner = match ? `${match[1] || ''}${match[2]}` : '';
|
||||
+ const rels = await this.parseRels([await this._readXmlEntry(entry)]);
|
||||
+ if (!rels) throw new Error(`Invalid relationships: ${entry.name}`);
|
||||
+ for (const rel of rels) {
|
||||
+ rel.Type = rel.Type.replace(/^http:\/\/purl\.oclc\.org\/ooxml\/officeDocument\/relationships\//,
|
||||
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/');
|
||||
+ if (!resolvedTypes.has(rel.Type)) continue;
|
||||
+ if (rel.TargetMode === 'External') throw new Error(`External workbook part: ${rel.Target}`);
|
||||
+ const target = this._resolvePart(owner, rel.Target);
|
||||
+ rel.Target = partNames.get(partKey(target));
|
||||
+ if (!rel.Target) throw new Error(`Missing workbook part: ${target}`);
|
||||
+ const type = partTypes.get(rel.Target);
|
||||
+ if (type && type !== rel.Type) throw new Error(`Conflicting workbook part: ${rel.Target}`);
|
||||
+ partTypes.set(rel.Target, rel.Type);
|
||||
+ }
|
||||
+ relationships.set(partKey(owner), rels);
|
||||
+ }
|
||||
+ model.globalRels = relationships.get('') || [];
|
||||
+ const officeDocuments = model.globalRels.filter(rel => rel.Type === XLSX.RelType.OfficeDocument);
|
||||
+ if (officeDocuments.length !== 1) throw new Error('Expected one workbook relationship');
|
||||
+ model.workbookRels = relationships.get(partKey(officeDocuments[0].Target));
|
||||
+ if (!model.workbookRels) throw new Error('Missing workbook relationships');
|
||||
+ for (const [path, type] of partTypes) {
|
||||
+ if (type === XLSX.RelType.Worksheet) model.worksheetRels[path] = relationships.get(partKey(path));
|
||||
+ }
|
||||
for (const entry of Object.values(zip.files)) {
|
||||
/* eslint-disable no-await-in-loop */
|
||||
if (!entry.dir) {
|
||||
@@ -15745,10 +15830,9 @@ class XLSX {
|
||||
if (entryName[0] === '/') {
|
||||
entryName = entryName.substr(1);
|
||||
}
|
||||
+ if (!partTypes.has(entryName) && !/\.(xml|rels|vml)$/i.test(entryName) && !entryName.match(/xl\/media\//)) continue;
|
||||
let stream;
|
||||
- if (entryName.match(/xl\/media\//) ||
|
||||
- // themes are not parsed as stream
|
||||
- entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/)) {
|
||||
+ if (entryName.match(/xl\/media\//)) {
|
||||
stream = new PassThrough();
|
||||
stream.write(await entry.async('nodebuffer'));
|
||||
} else {
|
||||
@@ -15757,46 +15841,48 @@ class XLSX {
|
||||
writableObjectMode: true,
|
||||
readableObjectMode: true
|
||||
});
|
||||
- let content;
|
||||
- // https://www.npmjs.com/package/process
|
||||
- if (process.browser) {
|
||||
- // running in browser, use TextDecoder if possible
|
||||
- content = bufferToString(await entry.async('nodebuffer'));
|
||||
- } else {
|
||||
- // running in node.js
|
||||
- content = await entry.async('string');
|
||||
- }
|
||||
+ const content = await this._readXmlEntry(entry);
|
||||
const chunkSize = 16 * 1024;
|
||||
for (let i = 0; i < content.length; i += chunkSize) {
|
||||
stream.write(content.substring(i, i + chunkSize));
|
||||
}
|
||||
}
|
||||
stream.end();
|
||||
- switch (entryName) {
|
||||
- case '_rels/.rels':
|
||||
- model.globalRels = await this.parseRels(stream);
|
||||
- break;
|
||||
- case 'xl/workbook.xml':
|
||||
- {
|
||||
- const workbook = await this.parseWorkbook(stream);
|
||||
- model.sheets = workbook.sheets;
|
||||
- model.definedNames = workbook.definedNames;
|
||||
- model.views = workbook.views;
|
||||
- model.properties = workbook.properties;
|
||||
- model.calcProperties = workbook.calcProperties;
|
||||
- break;
|
||||
- }
|
||||
- case 'xl/_rels/workbook.xml.rels':
|
||||
- model.workbookRels = await this.parseRels(stream);
|
||||
- break;
|
||||
- case 'xl/sharedStrings.xml':
|
||||
+ switch (partTypes.get(entryName)) {
|
||||
+ case XLSX.RelType.OfficeDocument: {
|
||||
+ const workbook = await this.parseWorkbook(stream);
|
||||
+ model.sheets = workbook.sheets;
|
||||
+ model.definedNames = workbook.definedNames;
|
||||
+ model.views = workbook.views;
|
||||
+ model.properties = workbook.properties;
|
||||
+ model.calcProperties = workbook.calcProperties;
|
||||
+ continue;
|
||||
+ }
|
||||
+ case XLSX.RelType.SharedStrings:
|
||||
model.sharedStrings = new SharedStringsXform();
|
||||
await model.sharedStrings.parseStream(stream);
|
||||
- break;
|
||||
- case 'xl/styles.xml':
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Styles:
|
||||
model.styles = new StylesXform();
|
||||
await model.styles.parseStream(stream);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Worksheet:
|
||||
+ await this._processWorksheetEntry(stream, model, entryName, options, entryName);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Comments:
|
||||
+ await this._processCommentEntry(stream, model, entryName);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Table:
|
||||
+ await this._processTableEntry(stream, model, entryName);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.VmlDrawing:
|
||||
+ await this._processVmlDrawingEntry(stream, model, entryName);
|
||||
+ continue;
|
||||
+ default:
|
||||
break;
|
||||
+ }
|
||||
+ switch (entryName) {
|
||||
+
|
||||
case 'docProps/app.xml':
|
||||
{
|
||||
const appXform = new AppXform();
|
||||
@@ -15814,17 +15900,7 @@ class XLSX {
|
||||
}
|
||||
default:
|
||||
{
|
||||
- let match = entryName.match(/xl\/worksheets\/sheet(\d+)[.]xml/);
|
||||
- if (match) {
|
||||
- await this._processWorksheetEntry(stream, model, match[1], options, entryName);
|
||||
- break;
|
||||
- }
|
||||
- match = entryName.match(/xl\/worksheets\/_rels\/sheet(\d+)[.]xml.rels/);
|
||||
- if (match) {
|
||||
- await this._processWorksheetRelsEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
- match = entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/);
|
||||
+ let match = entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/);
|
||||
if (match) {
|
||||
await this._processThemeEntry(stream, model, match[1]);
|
||||
break;
|
||||
@@ -15839,26 +15915,14 @@ class XLSX {
|
||||
await this._processDrawingEntry(stream, model, match[1]);
|
||||
break;
|
||||
}
|
||||
- match = entryName.match(/xl\/(comments\d+)[.]xml/);
|
||||
- if (match) {
|
||||
- await this._processCommentEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
- match = entryName.match(/xl\/tables\/(table\d+)[.]xml/);
|
||||
- if (match) {
|
||||
- await this._processTableEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
+
|
||||
+
|
||||
match = entryName.match(/xl\/drawings\/_rels\/([a-zA-Z0-9]+)[.]xml[.]rels/);
|
||||
if (match) {
|
||||
await this._processDrawingRelsEntry(stream, model, match[1]);
|
||||
break;
|
||||
}
|
||||
- match = entryName.match(/xl\/drawings\/(vmlDrawing\d+)[.]vml/);
|
||||
- if (match) {
|
||||
- await this._processVmlDrawingEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
+
|
||||
}
|
||||
}
|
||||
}
|
||||
diff --git a/lib/utils/parse-sax.js b/lib/utils/parse-sax.js
|
||||
index 14c682a731b5e51f999859a03ce4dd4e0bfcbb26..65c0f2b7ddd5c4e0c3a283f74c37a2fd22728aad 100644
|
||||
--- a/lib/utils/parse-sax.js
|
||||
+++ b/lib/utils/parse-sax.js
|
||||
@@ -8,15 +8,37 @@ module.exports = async function* (iterable) {
|
||||
if (iterable.pipe && !iterable[Symbol.asyncIterator]) {
|
||||
iterable = iterable.pipe(new PassThrough());
|
||||
}
|
||||
- const saxesParser = new SaxesParser();
|
||||
+ const saxesParser = new SaxesParser({xmlns: true});
|
||||
+ // Xforms use canonical names; OOXML producers may choose different prefixes.
|
||||
+ const prefixes = new Map([
|
||||
+ ['http://schemas.openxmlformats.org/spreadsheetml/2006/main', ''],
|
||||
+ ['http://purl.oclc.org/ooxml/spreadsheetml/main', ''],
|
||||
+ ['http://schemas.openxmlformats.org/package/2006/relationships', ''],
|
||||
+ ['http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'r'],
|
||||
+ ['http://purl.oclc.org/ooxml/officeDocument/relationships', 'r'],
|
||||
+ ['urn:schemas-microsoft-com:vml', 'v'],
|
||||
+ ['urn:schemas-microsoft-com:office:office', 'o'],
|
||||
+ ['urn:schemas-microsoft-com:office:excel', 'x'],
|
||||
+ ]);
|
||||
+ const canonicalName = node => {
|
||||
+ const prefix = prefixes.get(node.uri);
|
||||
+ return prefix === undefined ? node.name : prefix ? `${prefix}:${node.local}` : node.local;
|
||||
+ };
|
||||
let error;
|
||||
saxesParser.on('error', err => {
|
||||
error = err;
|
||||
});
|
||||
let events = [];
|
||||
- saxesParser.on('opentag', value => events.push({eventType: 'opentag', value}));
|
||||
+ saxesParser.on('opentag', node => {
|
||||
+ const attributes = Object.create(null);
|
||||
+ Object.values(node.attributes).forEach(attribute => {
|
||||
+ attributes[canonicalName(attribute)] = attribute.value;
|
||||
+ });
|
||||
+ events.push({eventType: 'opentag', value: {...node, name: canonicalName(node), attributes}});
|
||||
+ });
|
||||
saxesParser.on('text', value => events.push({eventType: 'text', value}));
|
||||
- saxesParser.on('closetag', value => events.push({eventType: 'closetag', value}));
|
||||
+ saxesParser.on('cdata', value => events.push({eventType: 'text', value}));
|
||||
+ saxesParser.on('closetag', node => events.push({eventType: 'closetag', value: {...node, name: canonicalName(node)}}));
|
||||
for await (const chunk of iterable) {
|
||||
saxesParser.write(bufferToString(chunk));
|
||||
// saxesParser.write and saxesParser.on() are synchronous,
|
||||
diff --git a/lib/xlsx/xform/book/workbook-xform.js b/lib/xlsx/xform/book/workbook-xform.js
|
||||
index 5c10a5857eba8d3fbf9d3841ff15b39aefde8862..c6c1dcd6fe739b37db48ae30d1b039d635e14aa5 100644
|
||||
--- a/lib/xlsx/xform/book/workbook-xform.js
|
||||
+++ b/lib/xlsx/xform/book/workbook-xform.js
|
||||
@@ -165,12 +165,11 @@ class WorkbookXform extends BaseXform {
|
||||
|
||||
(model.sheets || []).forEach(sheet => {
|
||||
const rel = rels[sheet.rId];
|
||||
- if (!rel) {
|
||||
- return;
|
||||
+ if (!rel) throw new Error(`Missing worksheet relationship: ${sheet.rId}`);
|
||||
+ worksheet = model.worksheetHash[rel.Target];
|
||||
+ if (!worksheet && rel.Type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet') {
|
||||
+ throw new Error(`Missing worksheet: ${rel.Target}`);
|
||||
}
|
||||
- // if rel.Target start with `[space]/xl/` or `/xl/` , then it will be replaced with `''` and spliced behind `xl/`,
|
||||
- // otherwise it will be spliced directly behind `xl/`. i.g.
|
||||
- worksheet = model.worksheetHash[`xl/${rel.Target.replace(/^(\s|\/xl\/)+/, '')}`];
|
||||
// If there are "chartsheets" in the file, rel.Target will
|
||||
// come out as chartsheets/sheet1.xml or similar here, and
|
||||
// that won't be in model.worksheetHash.
|
||||
diff --git a/lib/xlsx/xform/comment/comment-xform.js b/lib/xlsx/xform/comment/comment-xform.js
|
||||
index 16363bfea8d0903ac66c77606c4362ef3fcd72bb..dc0df108c5433c5447a37ecd163883f5fe24cf32 100644
|
||||
--- a/lib/xlsx/xform/comment/comment-xform.js
|
||||
+++ b/lib/xlsx/xform/comment/comment-xform.js
|
||||
@@ -74,6 +74,11 @@ utils.inherits(CommentXform, BaseXform, {
|
||||
...node.attributes,
|
||||
};
|
||||
return true;
|
||||
+ case 't':
|
||||
+ this.plainText = true;
|
||||
+ this.parser = this.richTextXform.textXform;
|
||||
+ this.parser.parseOpen(node);
|
||||
+ return true;
|
||||
case 'r':
|
||||
this.parser = this.richTextXform;
|
||||
this.parser.parseOpen(node);
|
||||
@@ -88,6 +93,12 @@ utils.inherits(CommentXform, BaseXform, {
|
||||
}
|
||||
},
|
||||
parseClose(name) {
|
||||
+ if (this.plainText && name === 't') {
|
||||
+ this.model.note.texts.push({text: this.parser.model});
|
||||
+ this.parser = undefined;
|
||||
+ this.plainText = false;
|
||||
+ return true;
|
||||
+ }
|
||||
switch (name) {
|
||||
case 'comment':
|
||||
return false;
|
||||
diff --git a/lib/xlsx/xform/sheet/worksheet-xform.js b/lib/xlsx/xform/sheet/worksheet-xform.js
|
||||
index b38930042cbb05dccd01c46a9adacb7a9fc59dba..e0b239143b4e80ecb0c2030c3bb7b48a803ebef8 100644
|
||||
--- a/lib/xlsx/xform/sheet/worksheet-xform.js
|
||||
+++ b/lib/xlsx/xform/sheet/worksheet-xform.js
|
||||
@@ -449,17 +449,17 @@ class WorkSheetXform extends BaseXform {
|
||||
// options.merges.reconcile(model.mergeCells, model.rows);
|
||||
const rels = (model.relationships || []).reduce((h, rel) => {
|
||||
h[rel.Id] = rel;
|
||||
- if (rel.Type === RelType.Comments) {
|
||||
- model.comments = options.comments[rel.Target].comments;
|
||||
- }
|
||||
- if (rel.Type === RelType.VmlDrawing && model.comments && model.comments.length) {
|
||||
- const vmlComment = options.vmlDrawings[rel.Target].comments;
|
||||
- model.comments.forEach((comment, index) => {
|
||||
- comment.note = Object.assign({}, comment.note, vmlComment[index]);
|
||||
- });
|
||||
- }
|
||||
return h;
|
||||
}, {});
|
||||
+ const commentRel = Object.values(rels).find(rel => rel.Type === RelType.Comments);
|
||||
+ if (commentRel) model.comments = options.comments[commentRel.Target].comments;
|
||||
+ const vmlRel = Object.values(rels).find(rel => rel.Type === RelType.VmlDrawing);
|
||||
+ if (vmlRel && model.comments && model.comments.length) {
|
||||
+ const vmlComments = options.vmlDrawings[vmlRel.Target].comments;
|
||||
+ model.comments.forEach((comment, index) => {
|
||||
+ comment.note = Object.assign({}, comment.note, vmlComments[index]);
|
||||
+ });
|
||||
+ }
|
||||
options.commentsMap = (model.comments || []).reduce((h, comment) => {
|
||||
if (comment.ref) {
|
||||
h[comment.ref] = comment;
|
||||
diff --git a/lib/xlsx/xlsx.js b/lib/xlsx/xlsx.js
|
||||
index ff2d9a117d78cacf727564f74770e1e8f19d0fa1..179d1dac2ed02bfc4b81f85afe7abd9822ef1a1d 100644
|
||||
--- a/lib/xlsx/xlsx.js
|
||||
+++ b/lib/xlsx/xlsx.js
|
||||
@@ -6,7 +6,7 @@ const StreamBuf = require('../utils/stream-buf');
|
||||
|
||||
const utils = require('../utils/utils');
|
||||
const XmlStream = require('../utils/xml-stream');
|
||||
-const {bufferToString} = require('../utils/browser-buffer-decode');
|
||||
+
|
||||
|
||||
const StylesXform = require('./xform/style/styles-xform');
|
||||
|
||||
@@ -146,6 +146,31 @@ class XLSX {
|
||||
delete model.vmlDrawings;
|
||||
}
|
||||
|
||||
+ // XML processors must accept UTF-8 and both byte orders of UTF-16.
|
||||
+ async _readXmlEntry(entry) {
|
||||
+ const bytes = await entry.async('uint8array');
|
||||
+ const encoding = (bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0x3c && bytes[1] === 0)
|
||||
+ ? 'utf-16le'
|
||||
+ : (bytes[0] === 0xfe && bytes[1] === 0xff) || (bytes[0] === 0 && bytes[1] === 0x3c)
|
||||
+ ? 'utf-16be' : 'utf-8';
|
||||
+ return new TextDecoder(encoding, {fatal: true}).decode(bytes);
|
||||
+ }
|
||||
+
|
||||
+ // Resolve an internal OPC target against its owning part, without filesystem access.
|
||||
+ _resolvePart(owner, target) {
|
||||
+ const parts = target.startsWith('/') ? [] : owner.split('/').slice(0, -1);
|
||||
+ for (const segment of target.split('/')) {
|
||||
+ if (!segment || segment === '.') continue;
|
||||
+ if (segment === '..') {
|
||||
+ if (!parts.length) throw new Error(`Relationship escapes package: ${target}`);
|
||||
+ parts.pop();
|
||||
+ } else {
|
||||
+ parts.push(segment);
|
||||
+ }
|
||||
+ }
|
||||
+ return parts.join('/');
|
||||
+ }
|
||||
+
|
||||
async _processWorksheetEntry(stream, model, sheetNo, options, path) {
|
||||
const xform = new WorksheetXform(options);
|
||||
const worksheet = await xform.parseStream(stream);
|
||||
@@ -157,19 +182,13 @@ class XLSX {
|
||||
async _processCommentEntry(stream, model, name) {
|
||||
const xform = new CommentsXform();
|
||||
const comments = await xform.parseStream(stream);
|
||||
- model.comments[`../${name}.xml`] = comments;
|
||||
+ model.comments[name] = comments;
|
||||
}
|
||||
|
||||
async _processTableEntry(stream, model, name) {
|
||||
const xform = new TableXform();
|
||||
const table = await xform.parseStream(stream);
|
||||
- model.tables[`../tables/${name}.xml`] = table;
|
||||
- }
|
||||
-
|
||||
- async _processWorksheetRelsEntry(stream, model, sheetNo) {
|
||||
- const xform = new RelationshipsXform();
|
||||
- const relationships = await xform.parseStream(stream);
|
||||
- model.worksheetRels[sheetNo] = relationships;
|
||||
+ model.tables[name] = table;
|
||||
}
|
||||
|
||||
async _processMediaEntry(entry, model, filename) {
|
||||
@@ -215,7 +234,7 @@ class XLSX {
|
||||
async _processVmlDrawingEntry(entry, model, name) {
|
||||
const xform = new VmlNotesXform();
|
||||
const vmlDrawing = await xform.parseStream(entry);
|
||||
- model.vmlDrawings[`../drawings/${name}.vml`] = vmlDrawing;
|
||||
+ model.vmlDrawings[name] = vmlDrawing;
|
||||
}
|
||||
|
||||
async _processThemeEntry(entry, model, name) {
|
||||
@@ -265,7 +284,7 @@ class XLSX {
|
||||
const model = {
|
||||
worksheets: [],
|
||||
worksheetHash: {},
|
||||
- worksheetRels: [],
|
||||
+ worksheetRels: Object.create(null),
|
||||
themes: {},
|
||||
media: [],
|
||||
mediaIndex: {},
|
||||
@@ -277,6 +296,49 @@ class XLSX {
|
||||
};
|
||||
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
+ const partKey = path => path.replace(/[A-Z]/g, character => character.toLowerCase());
|
||||
+ const partNames = new Map();
|
||||
+ for (const entry of Object.values(zip.files)) {
|
||||
+ if (entry.dir) continue;
|
||||
+ const key = partKey(entry.name);
|
||||
+ if (partNames.has(key)) throw new Error(`Ambiguous workbook part: ${entry.name}`);
|
||||
+ partNames.set(key, entry.name);
|
||||
+ }
|
||||
+ const partTypes = new Map();
|
||||
+ const relationships = new Map();
|
||||
+ const resolvedTypes = new Set([
|
||||
+ XLSX.RelType.OfficeDocument, XLSX.RelType.Styles, XLSX.RelType.SharedStrings,
|
||||
+ XLSX.RelType.Worksheet, XLSX.RelType.Comments, XLSX.RelType.Table, XLSX.RelType.VmlDrawing,
|
||||
+ ]);
|
||||
+ // Relationship order and ZIP entry order do not determine part identity.
|
||||
+ for (const entry of Object.values(zip.files)) {
|
||||
+ const match = entry.name.match(/^(.*\/)?_rels\/([^/]+)\.rels$/i);
|
||||
+ if (entry.dir || (!match && partKey(entry.name) !== '_rels/.rels')) continue;
|
||||
+ const owner = match ? `${match[1] || ''}${match[2]}` : '';
|
||||
+ const rels = await this.parseRels([await this._readXmlEntry(entry)]);
|
||||
+ if (!rels) throw new Error(`Invalid relationships: ${entry.name}`);
|
||||
+ for (const rel of rels) {
|
||||
+ rel.Type = rel.Type.replace(/^http:\/\/purl\.oclc\.org\/ooxml\/officeDocument\/relationships\//,
|
||||
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/');
|
||||
+ if (!resolvedTypes.has(rel.Type)) continue;
|
||||
+ if (rel.TargetMode === 'External') throw new Error(`External workbook part: ${rel.Target}`);
|
||||
+ const target = this._resolvePart(owner, rel.Target);
|
||||
+ rel.Target = partNames.get(partKey(target));
|
||||
+ if (!rel.Target) throw new Error(`Missing workbook part: ${target}`);
|
||||
+ const type = partTypes.get(rel.Target);
|
||||
+ if (type && type !== rel.Type) throw new Error(`Conflicting workbook part: ${rel.Target}`);
|
||||
+ partTypes.set(rel.Target, rel.Type);
|
||||
+ }
|
||||
+ relationships.set(partKey(owner), rels);
|
||||
+ }
|
||||
+ model.globalRels = relationships.get('') || [];
|
||||
+ const officeDocuments = model.globalRels.filter(rel => rel.Type === XLSX.RelType.OfficeDocument);
|
||||
+ if (officeDocuments.length !== 1) throw new Error('Expected one workbook relationship');
|
||||
+ model.workbookRels = relationships.get(partKey(officeDocuments[0].Target));
|
||||
+ if (!model.workbookRels) throw new Error('Missing workbook relationships');
|
||||
+ for (const [path, type] of partTypes) {
|
||||
+ if (type === XLSX.RelType.Worksheet) model.worksheetRels[path] = relationships.get(partKey(path));
|
||||
+ }
|
||||
for (const entry of Object.values(zip.files)) {
|
||||
/* eslint-disable no-await-in-loop */
|
||||
if (!entry.dir) {
|
||||
@@ -284,12 +346,9 @@ class XLSX {
|
||||
if (entryName[0] === '/') {
|
||||
entryName = entryName.substr(1);
|
||||
}
|
||||
+ if (!partTypes.has(entryName) && !/\.(xml|rels|vml)$/i.test(entryName) && !entryName.match(/xl\/media\//)) continue;
|
||||
let stream;
|
||||
- if (
|
||||
- entryName.match(/xl\/media\//) ||
|
||||
- // themes are not parsed as stream
|
||||
- entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/)
|
||||
- ) {
|
||||
+ if (entryName.match(/xl\/media\//)) {
|
||||
stream = new PassThrough();
|
||||
stream.write(await entry.async('nodebuffer'));
|
||||
} else {
|
||||
@@ -298,50 +357,47 @@ class XLSX {
|
||||
writableObjectMode: true,
|
||||
readableObjectMode: true,
|
||||
});
|
||||
- let content;
|
||||
- // https://www.npmjs.com/package/process
|
||||
- if (process.browser) {
|
||||
- // running in browser, use TextDecoder if possible
|
||||
- content = bufferToString(await entry.async('nodebuffer'));
|
||||
- } else {
|
||||
- // running in node.js
|
||||
- content = await entry.async('string');
|
||||
- }
|
||||
+ const content = await this._readXmlEntry(entry);
|
||||
const chunkSize = 16 * 1024;
|
||||
for (let i = 0; i < content.length; i += chunkSize) {
|
||||
stream.write(content.substring(i, i + chunkSize));
|
||||
}
|
||||
}
|
||||
stream.end();
|
||||
- switch (entryName) {
|
||||
- case '_rels/.rels':
|
||||
- model.globalRels = await this.parseRels(stream);
|
||||
- break;
|
||||
-
|
||||
- case 'xl/workbook.xml': {
|
||||
+ switch (partTypes.get(entryName)) {
|
||||
+ case XLSX.RelType.OfficeDocument: {
|
||||
const workbook = await this.parseWorkbook(stream);
|
||||
model.sheets = workbook.sheets;
|
||||
model.definedNames = workbook.definedNames;
|
||||
model.views = workbook.views;
|
||||
model.properties = workbook.properties;
|
||||
model.calcProperties = workbook.calcProperties;
|
||||
- break;
|
||||
+ continue;
|
||||
}
|
||||
-
|
||||
- case 'xl/_rels/workbook.xml.rels':
|
||||
- model.workbookRels = await this.parseRels(stream);
|
||||
- break;
|
||||
-
|
||||
- case 'xl/sharedStrings.xml':
|
||||
+ case XLSX.RelType.SharedStrings:
|
||||
model.sharedStrings = new SharedStringsXform();
|
||||
await model.sharedStrings.parseStream(stream);
|
||||
- break;
|
||||
-
|
||||
- case 'xl/styles.xml':
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Styles:
|
||||
model.styles = new StylesXform();
|
||||
await model.styles.parseStream(stream);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Worksheet:
|
||||
+ await this._processWorksheetEntry(stream, model, entryName, options, entryName);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Comments:
|
||||
+ await this._processCommentEntry(stream, model, entryName);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Table:
|
||||
+ await this._processTableEntry(stream, model, entryName);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.VmlDrawing:
|
||||
+ await this._processVmlDrawingEntry(stream, model, entryName);
|
||||
+ continue;
|
||||
+ default:
|
||||
break;
|
||||
-
|
||||
+ }
|
||||
+ switch (entryName) {
|
||||
case 'docProps/app.xml': {
|
||||
const appXform = new AppXform();
|
||||
const appProperties = await appXform.parseStream(stream);
|
||||
@@ -358,17 +414,7 @@ class XLSX {
|
||||
}
|
||||
|
||||
default: {
|
||||
- let match = entryName.match(/xl\/worksheets\/sheet(\d+)[.]xml/);
|
||||
- if (match) {
|
||||
- await this._processWorksheetEntry(stream, model, match[1], options, entryName);
|
||||
- break;
|
||||
- }
|
||||
- match = entryName.match(/xl\/worksheets\/_rels\/sheet(\d+)[.]xml.rels/);
|
||||
- if (match) {
|
||||
- await this._processWorksheetRelsEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
- match = entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/);
|
||||
+ let match = entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/);
|
||||
if (match) {
|
||||
await this._processThemeEntry(stream, model, match[1]);
|
||||
break;
|
||||
@@ -383,26 +429,11 @@ class XLSX {
|
||||
await this._processDrawingEntry(stream, model, match[1]);
|
||||
break;
|
||||
}
|
||||
- match = entryName.match(/xl\/(comments\d+)[.]xml/);
|
||||
- if (match) {
|
||||
- await this._processCommentEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
- match = entryName.match(/xl\/tables\/(table\d+)[.]xml/);
|
||||
- if (match) {
|
||||
- await this._processTableEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
match = entryName.match(/xl\/drawings\/_rels\/([a-zA-Z0-9]+)[.]xml[.]rels/);
|
||||
if (match) {
|
||||
await this._processDrawingRelsEntry(stream, model, match[1]);
|
||||
break;
|
||||
}
|
||||
- match = entryName.match(/xl\/drawings\/(vmlDrawing\d+)[.]vml/);
|
||||
- if (match) {
|
||||
- await this._processVmlDrawingEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
}
|
||||
}
|
||||
}
|
||||
diff --git a/package.json b/package.json
|
||||
index 7a70a5e13145a917b57806a6998b8d5e04f47acd..8a1c0a87b0140412517ab7a0fa871afdfbe22b6c 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -16,7 +16,7 @@
|
||||
"node": ">=8.3.0"
|
||||
},
|
||||
"main": "./excel.js",
|
||||
- "browser": "./dist/exceljs.min.js",
|
||||
+ "browser": "./dist/exceljs.js",
|
||||
"types": "./index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
Generated
+6
-2
@@ -15,6 +15,7 @@ patchedDependencies:
|
||||
'@fortune-sheet/[email protected]': 488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0
|
||||
'@fortune-sheet/[email protected]': c17549e117c8fb99f408497c1e84292c21f25516fb762a32518f91b4d9ab0f04
|
||||
'@yao-pkg/[email protected]': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7
|
||||
[email protected]: c1bf8ac8f319dab382f08e3a669b715e691d46c703ac5a4163eb383fbedd1d48
|
||||
[email protected]: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0
|
||||
|
||||
importers:
|
||||
@@ -4477,6 +4478,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-client-ui-theme':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-theme
|
||||
'@deepseek-ai/dsh-deepseek-account':
|
||||
specifier: workspace:^
|
||||
version: link:../../credentials/deepseek-account
|
||||
@@ -5040,7 +5044,7 @@ importers:
|
||||
version: 3.4.11
|
||||
exceljs:
|
||||
specifier: 4.4.0
|
||||
version: 4.4.0
|
||||
version: 4.4.0(patch_hash=c1bf8ac8f319dab382f08e3a669b715e691d46c703ac5a4163eb383fbedd1d48)
|
||||
fast-xml-parser:
|
||||
specifier: 5.11.1
|
||||
version: 5.11.1
|
||||
@@ -25687,7 +25691,7 @@ snapshots:
|
||||
dependencies:
|
||||
eventsource-parser: 3.1.0
|
||||
|
||||
[email protected]:
|
||||
[email protected](patch_hash=c1bf8ac8f319dab382f08e3a669b715e691d46c703ac5a4163eb383fbedd1d48):
|
||||
dependencies:
|
||||
archiver: 5.3.2
|
||||
dayjs: 1.11.21
|
||||
|
||||
@@ -100,4 +100,5 @@ patchedDependencies:
|
||||
'@fortune-sheet/[email protected]': patches/@[email protected]
|
||||
'@fortune-sheet/[email protected]': patches/@[email protected]
|
||||
'@yao-pkg/[email protected]': patches/@[email protected]
|
||||
[email protected]: patches/[email protected]
|
||||
[email protected]: patches/[email protected]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Independent-writer Excel previews
|
||||
|
||||
- comments.xlsx: 数据 opened; Item copied
|
||||
- table.xlsx: 数据 opened; Item copied
|
||||
- prefix.xlsx: 数据 opened; Item copied
|
||||
- part-name.xlsx: 数据 opened; Item copied
|
||||
- relative.xlsx: 数据 opened; Item copied
|
||||
- combined.xlsx: 数据 opened; Item copied
|
||||
- strict.xlsx: 数据 opened; Item copied
|
||||
- case-root.xlsx: 数据 opened; Item copied
|
||||
- case-parts.xlsx: 数据 opened; Item copied
|
||||
- orphan-drawing.xlsx: 数据 opened; Item copied
|
||||
- rich.xlsx: 数据 opened; Item copied
|
||||
- utf16-sheet.xlsx: 数据 opened; Item copied
|
||||
- utf16-metadata.xlsx: 数据 opened; Item copied
|
||||
- workbook-path.xlsx: 数据 opened; Item copied
|
||||
- styles-path.xlsx: 数据 opened; Item copied
|
||||
- strings-path.xlsx: 数据 opened; Item copied
|
||||
- cdata.xlsx: 数据 opened; Item copied
|
||||
- parts-path.xlsx: 数据 opened; Item copied
|
||||
- Relocated workbook: hidden sheet omitted
|
||||
- Relocated workbook notice: This preview does not support charts, images, shapes, conditional formatting in this workbook. Open it in a system application for the full experience.
|
||||
@@ -0,0 +1,9 @@
|
||||
version: 1
|
||||
scenario: excel-opc
|
||||
profile: web
|
||||
composition: web-default
|
||||
recording: authored
|
||||
header:
|
||||
class: web-default
|
||||
session:
|
||||
source: ../lifecycle-chrome/session.v3.jsonl
|
||||
@@ -48,6 +48,7 @@
|
||||
"apps/web/tests/details-session-lifecycle.e2e.ts",
|
||||
"apps/web/tests/sessionless-header.e2e.ts",
|
||||
"apps/web/tests/document-preview.e2e.ts",
|
||||
"apps/web/tests/excel-opc.e2e.ts",
|
||||
"apps/web/tests/office-fixture.ts",
|
||||
"apps/web/tests/sidebar-browser.e2e.ts",
|
||||
"apps/web/tests/plugin-config.e2e.ts",
|
||||
|
||||
Reference in new issue
Block a user