From 574643a2e91e1647db81d24465400f5efaad874d Mon Sep 17 00:00:00 2001 From: gdz66601 Date: Wed, 23 Sep 2026 01:01:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(files):=20=E6=96=B0=E5=A2=9E=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=20/=20add=20dedicated=20file=20management=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 6 + .gitignore | 2 + DEPLOYMENT.md | 2 + README.md | 9 +- frontend/index.html | 2 +- frontend/src/dashboard.ts | 51 ++++++++- frontend/src/file-manager.ts | 3 +- frontend/src/file-page.css | 115 +++++++++++++++++++ frontend/src/file-page.ts | 166 ++++++++++++++++++++++++++++ frontend/src/main.ts | 32 +++++- package-lock.json | 46 ++++++++ package.json | 2 + playwright.config.ts | 22 ++++ tests/browser/file-fixture.ts | 113 +++++++++++++++++++ tests/browser/files.spec.ts | 200 ++++++++++++++++++++++++++++++++++ 15 files changed, 760 insertions(+), 11 deletions(-) create mode 100644 frontend/src/file-page.css create mode 100644 frontend/src/file-page.ts create mode 100644 playwright.config.ts create mode 100644 tests/browser/file-fixture.ts create mode 100644 tests/browser/files.spec.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f7607d5..79ef2af 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -59,6 +59,12 @@ jobs: - name: Check project run: npm run check + # 在模拟协议上回归共享文件组件,防止页面改动破坏终端侧栏或误操作真实主机。 + - name: Check file manager in desktop and mobile browsers + run: | + npx playwright install --with-deps chromium + npm run test:browser -- --workers=2 + - name: Configure selected login, provision D1, migrate and deploy Worker run: npm run deploy env: diff --git a/.gitignore b/.gitignore index 1e53566..e4e2dbd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ deliverables/ frontend/dist-* scripts-tmp/ .wrangler.generated.toml +test-results/ +playwright-report/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 57563b2..c9bd9b7 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -134,3 +134,5 @@ Cloudflare 模式仍可用 `ACCESS_IDP_IDS` 为新 Access 应用选择现成 IdP - 登录后 `/api/auth/me` 与主机列表可用;无 JWT、伪造/过期 JWT 不可访问主机、票据或 WebSocket。 - 加密资料跨部署保持可解密;测试不得清空生产 D1。 - 真实 SSH、SFTP 与进程面板必须使用已获授权目标;未提供目标和登录会话时不声称验收完成。 +- 文件管理浏览器回归:`npx playwright install chromium` 后运行 `npm run test:browser`,覆盖桌面/375px 手机的入口、目录导航、上传/下载字节、新建/重命名/删除、主机切换、指纹确认与传输期间导航。测试只模拟 API 和 WebSocket,不替代真实服务器验收。 +- 线上登录后,从左侧「文件管理」选择授权主机,在专用临时目录验证上传、下载、新建、重命名与删除空目录,再切换到终端确认同一会话仍可用。不得用既有生产文件验证删除或覆盖。 diff --git a/README.md b/README.md index dbcdf52..0cd2824 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@

📁 SFTP 文件管理

-

在终端旁浏览远程目录,上传与下载文件、新建目录、重命名,以及删除文件或空目录。

+

从左侧「文件管理」进入专用页面,选择已保存主机,浏览目录、上传与下载、新建文件夹、重命名、删除文件或空目录;与终端侧栏共用同一文件组件与 SSH 会话。

@@ -186,6 +186,7 @@ npm run dev | `npm run build:web` | 构建前端到 `dist/` | | `npm run typecheck` | 检查 Worker 与前端类型 | | `npm test` | 运行测试 | +| `npm run test:browser` | 文件管理浏览器回归(首次运行前执行 `npx playwright install chromium`) | | `npm run check` | 类型检查、测试与部署 dry-run(包含前端构建) | | `npm run deploy` | 构建前端并部署 Worker | @@ -228,6 +229,12 @@ EdgeSSH/ 暂不支持加密私钥、PEM/PKCS#8 私钥、SSH Agent、多因素键盘交互认证、SCP、端口转发、ProxyJump、SSH 压缩与会话内 rekey;不支持出站 TCP 25 端口。 +### 独立文件管理 + +左侧点击 **文件管理**,选择云端保存的主机并连接。首次连接或指纹变化时仍须确认主机身份。双击目录进入,或在路径栏输入绝对路径后按 Enter;选中文件后可下载、重命名、删除,也可上传文件和新建文件夹。 + +文件页继续使用原有完整 SSH 会话,不要求服务器提供独立的 SFTP-only 入口;不会执行主机配置中的初始命令。点击 **打开 SSH 终端** 可继续使用同一会话,文件传输不会因视图切换而中断。切换主机前先断开连接;离开到总览会关闭会话,正在传输时会要求确认。 + ## 参与贡献 欢迎通过 Issue 反馈问题或通过 Pull Request 提交改进。提交前请运行 `npm run check`;涉及连接、文件传输或进程面板的改动,请使用真实且已获授权的 SSH 目标验证。 diff --git a/frontend/index.html b/frontend/index.html index c51b424..0b843c6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -480,7 +480,7 @@ - + diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index bd19b92..0bf4ab3 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -1,10 +1,12 @@ import { api, saveHost, removeHost, refreshHostLocation, type CloudHost, type HostInput } from './cloud-api'; import type { HostGlobe } from './globe'; +import type { FilePage } from './file-page'; import { countryFlag } from './flags'; import { systemIcon } from './os-icons'; import './dashboard.css'; interface DashboardActions { + files: FilePage; refresh(): Promise; connect(host: CloudHost): Promise; quickConnect(): void; @@ -15,6 +17,7 @@ const icons = { home: '', server: '', terminal: '', + folder: '', shield: '', search: '', refresh: '', @@ -46,8 +49,9 @@ export class Dashboard {
@@ -100,6 +104,7 @@ export class Dashboard { `; document.body.prepend(this.root); + this.get('.home-layout').append(this.actions.files.root); this.dialog = this.get('.host-dialog'); this.form = this.get('#cloud-host-form'); this.get('#account-action').addEventListener('click', async (event) => { @@ -117,9 +122,16 @@ export class Dashboard { this.dialog.addEventListener('close', () => { this.form.reset(); this.editing = undefined; this.returnFocus?.focus(); }); this.get('#host-search').addEventListener('input', () => this.renderList()); this.get('#refresh-hosts').addEventListener('click', () => void this.refresh()); - this.get('#rail-hosts').addEventListener('click', () => this.get('#host-search').focus()); + for (const id of ['#rail-overview', '#rail-hosts']) this.get(id).addEventListener('click', () => { + if (!this.actions.files.confirmLeave()) return; + if (!this.isHome) this.actions.leaveWorkspace(); + this.show(); + if (id === '#rail-hosts') this.get('#host-search').focus(); + }); + this.get('#rail-files').addEventListener('click', () => this.showFiles()); for (const id of ['#quick-connect', '#bottom-quick']) this.get(id).addEventListener('click', () => { - if (this.busy) return; + if (this.busy || !this.actions.files.confirmLeave()) return; + this.actions.leaveWorkspace(); this.openWorkspace(); this.actions.quickConnect(); }); this.field('authMethod').addEventListener('change', () => this.updateCredentialFields()); @@ -137,8 +149,15 @@ export class Dashboard { }); const back = document.createElement('button'); back.className = 'icon-button home-back'; back.textContent = '← 主机总览'; back.type = 'button'; - back.addEventListener('click', () => { this.actions.leaveWorkspace(); this.show(); void this.refresh(); }); + back.addEventListener('click', () => { + if (!this.actions.files.confirmLeave()) return; + this.actions.leaveWorkspace(); this.show(); void this.refresh(); + }); document.querySelector('#app .topbar-actions')!.prepend(back); + const files = document.createElement('button'); + files.className = 'icon-button home-back'; files.textContent = '文件管理'; files.type = 'button'; + files.addEventListener('click', () => this.showFiles()); + document.querySelector('#app .topbar-actions')!.prepend(files); this.show(); } @@ -171,6 +190,7 @@ export class Dashboard { setHosts(hosts: CloudHost[]): void { this.hosts = hosts; + this.actions.files.setHosts(hosts); if (this.group && !hosts.some((host) => host.group === this.group)) this.group = ''; this.renderFilters(); this.renderList(); this.globe?.setHosts(hosts); @@ -187,13 +207,17 @@ export class Dashboard { } show(): void { + this.actions.files.hide(); this.isHome = true; this.root.hidden = false; + this.get('.home-content').hidden = false; + this.selectNavigation('rail-overview'); document.getElementById('app')!.hidden = true; document.body.dataset.view = 'dashboard'; this.globe?.setActive(!this.paused); } openWorkspace(): void { + this.actions.files.hide(); this.isHome = false; this.root.hidden = true; document.getElementById('app')!.hidden = false; document.body.dataset.view = 'workspace'; @@ -201,11 +225,30 @@ export class Dashboard { window.dispatchEvent(new Event('resize')); } + showFiles(): void { + this.isHome = false; this.root.hidden = false; + this.get('.home-content').hidden = true; + document.getElementById('app')!.hidden = true; + document.body.dataset.view = 'files'; + this.selectNavigation('rail-files'); + this.globe?.setActive(false); + this.actions.files.show(); + } + + private selectNavigation(id: string): void { + this.root.querySelectorAll('.rail-item').forEach((button) => { + button.classList.toggle('selected', button.id === id); + if (button.id === id) button.setAttribute('aria-current', 'page'); + else button.removeAttribute('aria-current'); + }); + } + private notice(message: string): void { const notice = this.get('#home-notice'); notice.textContent = message; notice.hidden = false; } private signedOut(): void { + this.actions.leaveWorkspace(); this.authenticated = false; this.get('#account-label').textContent = '未登录'; const action = this.get('#account-action'); diff --git a/frontend/src/file-manager.ts b/frontend/src/file-manager.ts index d317e01..3d5aebb 100644 --- a/frontend/src/file-manager.ts +++ b/frontend/src/file-manager.ts @@ -1180,7 +1180,8 @@ export class FileManager { this.elements.path.disabled = !this.ready; this.elements.back.disabled = !this.ready || this.historyIndex <= 0; this.elements.up.disabled = !this.ready || this.cwd === '/'; - this.elements.home.disabled = !this.ready || this.cwd === this.homePath; + // 列目录失败时 cwd 仍是上次成功路径,允许用主目录按钮恢复,而不把错误页锁在原处。 + this.elements.home.disabled = !this.ready || (this.cwd === this.homePath && this.elements.error.hidden); this.elements.refresh.disabled = !this.ready; this.elements.upload.disabled = !idle; this.elements.mkdir.disabled = !idle; diff --git a/frontend/src/file-page.css b/frontend/src/file-page.css new file mode 100644 index 0000000..f397144 --- /dev/null +++ b/frontend/src/file-page.css @@ -0,0 +1,115 @@ +/* 文件页沿用总览的白橙主题;局部映射共享面板令牌,不改变终端侧栏的紧凑布局。 */ +body[data-view='files'] { height: auto; min-height: 100%; overflow-y: auto; background: var(--home-bg); } +.files-page { + --surface: var(--home-surface); + --surface-2: var(--home-bg); + --surface-3: var(--home-line); + --panel: var(--home-surface); + --border: var(--home-line); + --border-soft: var(--home-line); + --text: var(--home-ink); + --muted: var(--home-muted); + --muted-2: var(--home-muted); + --accent: var(--home-accent-hover); + --accent-dim: var(--home-accent-soft); + --danger: var(--home-danger); + --danger-dim: var(--home-accent-soft); + --warning: var(--home-accent-hover); + --sans: var(--home-font); + --mono: var(--home-mono); + min-width: 0; width: 100%; max-width: 1700px; margin-inline: auto; + padding: 32px; display: flex; flex-direction: column; gap: 20px; +} +.files-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.files-heading h1 { margin: 0; font-size: 29px; line-height: 1.3; letter-spacing: -.8px; } +.files-heading p:last-child { margin: 8px 0 0; color: var(--home-muted); } +.files-connection { display: flex; align-items: center; gap: 16px; padding: 18px 20px; border: 1px solid var(--home-line); border-radius: 10px; background: var(--home-surface); } +.files-host-icon { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 9px; background: var(--home-accent-soft); color: var(--home-accent-hover); flex-shrink: 0; } +.files-host-field { display: flex; flex-direction: column; gap: 6px; flex: 1; max-width: 510px; min-width: 0; color: var(--home-muted); font-size: 12px; } +#dashboard .files-host-field select { width: 100%; min-width: 0; height: 42px; padding: 0 32px 0 12px; color: var(--home-ink); background: var(--home-bg); border: 1px solid var(--home-line); border-radius: 6px; font: 13px var(--home-mono); } +#files-connect { margin-top: 24px; min-width: 104px; } +.files-connection-meta { margin-left: auto; display: flex; flex-direction: column; align-items: flex-end; gap: 8px; font-size: 12px; color: var(--home-muted); text-align: right; } +.files-state { display: inline-flex; align-items: center; gap: 7px; color: var(--home-muted); font-weight: 600; } +.files-state::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.files-state[data-state='connected'] { color: var(--home-success); } +.files-state[data-state='error'] { color: var(--home-danger); } +.files-state[data-state='connecting'] { color: var(--home-accent-hover); } +.files-notice { margin: 0; padding: 10px 14px; border-left: 3px solid var(--home-accent); background: var(--home-accent-soft); color: var(--home-ink); font-size: 13px; overflow-wrap: anywhere; } +.files-notice.error { color: var(--home-danger); border-color: var(--home-danger); } +.files-browser { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--home-line); border-radius: 10px; overflow: hidden; background: var(--home-surface); } +.files-browser-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 20px; border-bottom: 1px solid var(--home-line); } +.files-browser-heading strong { display: flex; align-items: center; gap: 10px; font-size: 14px; font-weight: 600; } +.files-browser-heading strong svg { color: var(--home-accent-hover); } +.files-browser-heading > span { color: var(--home-muted); font: 11px var(--home-mono); } +.files-panel-slot { display: grid; min-width: 0; height: clamp(390px, calc(100dvh - 490px), 850px); } +.files-page .file-manager { grid-template-rows: auto minmax(0, 1fr) auto; } +.files-page .file-toolbar { flex-wrap: wrap; gap: 12px; padding: 14px 18px; } +.files-page .file-path-field { flex-basis: 100%; } +.files-page .file-path-field input { height: 40px; font-size: 13px; padding-inline: 12px; } +.files-page .file-actions { flex: 1; flex-wrap: wrap; gap: 6px; overflow: visible; } +#dashboard .files-page .file-action { min-width: 38px; height: 38px; border: 1px solid var(--home-line); background: var(--home-surface); font-size: 14px; } +#dashboard .files-page .file-action-wide { padding-inline: 14px; font-size: 13px; font-weight: 500; } +#dashboard .files-page #file-upload { background: var(--home-accent-hover); color: var(--home-surface); border-color: var(--home-accent-hover); } +#dashboard .files-page #file-delete { color: var(--home-danger); margin-left: auto; } +#dashboard .files-page button:disabled { cursor: not-allowed; } +.files-page .file-tree { background: var(--home-bg); } +.files-page .file-tree-header { height: 42px; padding-inline: 14px; background: var(--home-bg); font-size: 12px; text-transform: none; } +#dashboard .files-page .file-tree-collapse { width: 30px; height: 30px; font-size: 14px; } +.files-page .file-manager:has(> .file-tree.is-collapsed) { grid-template-columns: 40px minmax(0, 1fr); } +.files-page .file-tree-scroller { padding-block: 8px; } +.files-page .file-tree-row { min-height: 34px; gap: 8px; font-size: 12px; } +.files-page .file-tree-icon { font-size: 14px; } +.files-page .file-table { font-size: 12px; } +.files-page .file-table th { height: 42px; padding-inline: 12px; font-size: 12px; font-weight: 500; border-right: 0; } +.files-page .file-table td { height: 42px; padding-inline: 12px; } +.files-page .file-table th:first-child { width: 32%; } +.files-page .file-table th:nth-child(6), .files-page .file-table th:nth-child(7) { width: 8.5%; } +.files-page .file-kind-icon { width: 20px; flex-basis: 20px; font-size: 16px; } +.files-page .file-manager-state { font-size: 14px; line-height: 1.8; } +.files-page .file-statusbar { min-height: 42px; padding: 8px 16px; font-size: 11px; flex-wrap: wrap; } +.files-page .file-progress { max-width: 100%; } +#dashboard .files-page .file-progress-cancel { height: 30px; color: var(--home-ink); background: var(--home-bg); } +.files-footer { display: flex; justify-content: space-between; gap: 12px; color: var(--home-muted); font-size: 12px; } +body[data-view='files'] #dashboard .home-search, body[data-view='files'] #dashboard .header-add { display: none; } +body[data-view='files'] .home-account { margin-left: auto; } +@media (max-width: 1100px) { + .files-page { padding: 24px; } + .files-connection { flex-wrap: wrap; } + .files-connection-meta { flex-basis: 100%; flex-direction: row; align-items: center; justify-content: space-between; } + .files-page .file-manager { grid-template-columns: 180px minmax(0, 1fr); } + .files-page .file-table :is(th, td):nth-child(n+5) { display: none; } +} +@media (max-width: 600px) { + .files-page { padding: 20px 12px; gap: 16px; } + .files-heading { align-items: flex-start; } + .files-heading h1 { font-size: 25px; } + .files-heading p:last-child { font-size: 12px; } + #dashboard #files-terminal { min-height: 44px; padding-inline: 10px; font-size: 12px; } + .files-connection { padding: 14px; gap: 12px; } + .files-host-icon { display: none; } + .files-host-field { flex-basis: calc(100% - 116px); } + #dashboard .files-host-field select { height: 44px; font-size: 16px; } + #files-connect { min-height: 44px; } + .files-connection-meta { align-items: flex-start; text-align: left; font-size: 11px; } + .files-connection-meta > span:last-child { max-width: 65%; } + .files-browser-heading { padding: 14px 12px; } + .files-browser-heading > span { font-size: 10px; } + .files-page .file-toolbar { padding: 12px; } + .files-page .file-path-field input { font-size: 16px; height: 44px; } + #dashboard .files-page .file-action { min-width: 44px; height: 44px; } + #dashboard .files-page .file-action-wide { padding-inline: 10px; } + .files-page .file-action-separator { display: none; } + .files-page .file-manager { grid-template-columns: minmax(0, 1fr); grid-template-areas: "toolbar" "table" "status"; } + .files-page .file-manager > .file-tree { display: none; } + .files-page .file-manager:has(> .file-tree.is-collapsed) { grid-template-columns: minmax(0, 1fr); } + .files-page .file-table th:first-child { width: 48%; } + .files-page .file-table th:nth-child(2) { width: 20%; } + .files-page .file-table th:nth-child(4) { width: 32%; } + .files-page .file-table :is(th, td):nth-child(3) { display: none; } + .files-page .file-table td { height: 48px; } + .files-panel-slot { height: 560px; } + .files-footer { flex-direction: column; gap: 6px; } + #dashboard .home-rail { gap: 4px; padding-inline: 8px; justify-content: space-between; } + #dashboard .rail-item { padding-inline: 8px; font-size: 11px; } + #dashboard .rail-item svg { width: 18px; height: 18px; } +} diff --git a/frontend/src/file-page.ts b/frontend/src/file-page.ts new file mode 100644 index 0000000..35953b0 --- /dev/null +++ b/frontend/src/file-page.ts @@ -0,0 +1,166 @@ +import type { CloudHost } from './cloud-api'; +import './file-page.css'; + +export type FileConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnecting' | 'error'; + +interface FilePageActions { + connect(host: CloudHost): Promise; + disconnect(): void; + openTerminal(): void; +} + +const folderIcon = ''; + +/** 页面只负责主机选择和布局;移动同一个面板,避免为同一 SSH 会话重复附着 SFTP。 */ +export class FilePage { + readonly root = document.createElement('main'); + private readonly dockMarker = document.createComment('shared-file-manager'); + private readonly select: HTMLSelectElement; + private readonly connectButton: HTMLButtonElement; + private hosts: CloudHost[] = []; + private state: FileConnectionState = 'idle'; + private preparing = false; + private mounted = false; + private activeTarget: { id?: string; label: string } | null = null; + + constructor(private readonly panel: HTMLElement, private readonly actions: FilePageActions) { + panel.before(this.dockMarker); + this.root.className = 'files-page'; + this.root.hidden = true; + this.root.innerHTML = ` +
+

REMOTE FILES

文件管理

+

在服务器之间切换,让每一份文件井然有序。

+ +
+
+ ${folderIcon} + + +
未连接 + 使用已保存的 SSH 凭据安全连接
+
+ +
+
${folderIcon}远程目录SFTP · 加密传输
+
+
+
双击目录进入 · 选中文件后操作 · 路径栏按 Enter 跳转 + 单文件 ≤ 64 MiB · 仅支持删除空目录
`; + this.select = this.get('#files-host'); + this.connectButton = this.get('#files-connect'); + this.select.addEventListener('change', () => this.renderConnection()); + this.connectButton.addEventListener('click', () => void this.toggleConnection()); + this.get('#files-terminal').addEventListener('click', () => this.actions.openTerminal()); + } + + private get(selector: string): T { + return this.root.querySelector(selector)!; + } + + show(): void { + this.mounted = true; + this.root.hidden = false; + this.get('.files-panel-slot').append(this.panel); + this.panel.hidden = false; + this.panel.setAttribute('role', 'region'); + this.panel.setAttribute('aria-labelledby', 'files-heading'); + this.get('#files-heading').focus(); + } + + hide(): void { + if (!this.mounted) return; + this.mounted = false; + this.root.hidden = true; + this.dockMarker.after(this.panel); + this.panel.setAttribute('role', 'tabpanel'); + this.panel.setAttribute('aria-labelledby', 'file-manager-tab'); + this.panel.hidden = document.getElementById('file-manager-tab')!.getAttribute('aria-selected') !== 'true'; + } + + setHosts(hosts: CloudHost[]): void { + const selected = this.select.value; + this.hosts = hosts; + this.select.replaceChildren(new Option(hosts.length ? '请选择主机' : '暂无主机,请先在总览添加', '')); + for (const host of hosts) { + this.select.add(new Option(`${host.name} · ${host.username}@${host.host}:${host.port}`, host.id)); + } + this.select.value = hosts.some((host) => host.id === selected) ? selected : ''; + this.selectActiveTarget(); + this.renderConnection(); + } + + setConnection(state: FileConnectionState, hostId?: string, target = ''): void { + this.state = state; + this.activeTarget = state === 'connecting' || state === 'connected' ? { id: hostId, label: target } : null; + this.selectActiveTarget(); + this.renderConnection(); + } + + private selectActiveTarget(): void { + this.select.querySelector('[data-current-session]')?.remove(); + if (!this.activeTarget) return; + if (this.hosts.some((host) => host.id === this.activeTarget!.id)) { + this.select.value = this.activeTarget.id!; + } else { + // 临时连接也能进入文件页,但不能把旧的下拉选项误标成当前服务器。 + const option = new Option(`当前会话 · ${this.activeTarget.label}`, ''); + option.dataset.currentSession = 'true'; + this.select.add(option); + option.selected = true; + } + } + + setMessage(message: string, error = false): void { + const notice = this.get('#files-notice'); + notice.textContent = message; + notice.classList.toggle('error', error); + notice.hidden = !message; + } + + private renderConnection(): void { + const active = this.state === 'connecting' || this.state === 'connected'; + const busy = this.preparing || this.state === 'disconnecting'; + this.select.disabled = active || busy || !this.hosts.length; + this.connectButton.disabled = busy || (!active && !this.select.value); + this.get('#files-terminal').disabled = this.preparing; + this.connectButton.classList.toggle('primary', !active); + this.connectButton.textContent = this.preparing ? '读取凭据中…' + : this.state === 'connecting' ? '取消连接' + : this.state === 'connected' ? '断开连接' + : this.state === 'disconnecting' ? '正在断开…' : '连接主机'; + const labels: Record = { + idle: '未连接', connecting: '正在连接', connected: 'SSH 已连接', disconnecting: '正在断开', error: '连接失败', + }; + const badge = this.get('#files-connection-state'); + badge.textContent = labels[this.state]; + badge.dataset.state = this.state; + this.get('#files-host-hint').textContent = active + ? '与终端共用会话 · 切换主机前请先断开' + : '使用已保存的 SSH 凭据安全连接'; + } + + private async toggleConnection(): Promise { + if (this.state === 'connecting' || this.state === 'connected') { + if (!this.confirmLeave()) return; + this.actions.disconnect(); + return; + } + const host = this.hosts.find((item) => item.id === this.select.value); + if (!host || this.preparing || this.state === 'disconnecting') return; + this.preparing = true; + this.renderConnection(); + this.setMessage(`正在读取 ${host.name} 的连接凭据…`); + try { await this.actions.connect(host); } + catch (error) { this.setMessage(error instanceof Error ? error.message : '连接准备失败,请重试。', true); } + finally { this.preparing = false; this.renderConnection(); } + } + + confirmLeave(): boolean { + // 只有正在传输时才打断导航,避免用户无意中取消上传或下载。 + const progress = this.panel.querySelector('#file-manager-progress')!; + return progress.hidden || confirm('文件正在传输,离开或断开连接将取消传输。仍要继续吗?'); + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 6e4019a..bb0199e 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -4,6 +4,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links'; import { historyKey, historyLabel } from './history'; import { listHosts, hostCredentials, saveHost, removeHost, updateHostSystem, type CloudHost, type Credentials, type HostSystemInfo } from './cloud-api'; import { Dashboard } from './dashboard'; +import { FilePage } from './file-page'; import { resolveConnectionControl, resolveConnectionPanel } from './ui-state'; import { classifyHostKey, SSH_FINGERPRINT_RE, type HostKeyPrompt } from './host-key'; import { FileManager, collectFileManagerElements } from './file-manager'; @@ -370,6 +371,7 @@ function applyLanguage(language: Language, persist = false): void { let profiles: SavedProfile[] = []; let dashboard: Dashboard | undefined; +let filePage: FilePage | undefined; let hostKeys: Record = {}; let socket: WebSocket | null = null; let connectionState: ConnectionState = 'idle'; @@ -1065,6 +1067,7 @@ function showFormError(message: string, alternate?: string): void { currentFormError = messageTranslation(message, alternate); ui.formError.textContent = localize(currentFormError); ui.formError.hidden = false; + if (document.body.dataset.view === 'files') filePage?.setMessage(localize(currentFormError), true); } function toast(message: string, kind: 'info' | 'error' = 'info'): void { @@ -1079,6 +1082,7 @@ function updateConnectionStatus(message: LocalizedMessage): void { currentSessionSubtitle = message; const text = localize(message); ui.sessionSubtitle.textContent = text; + filePage?.setMessage(text); if (connectionState === 'connecting') { const btnSpan = ui.connect.querySelector('span:last-child'); if (btnSpan) btnSpan.textContent = text; @@ -1087,6 +1091,7 @@ function updateConnectionStatus(message: LocalizedMessage): void { function setState(state: ConnectionState, label?: string): void { connectionState = state; + filePage?.setConnection(state, profiles.find((profile) => passwordContext(profile) === currentTargetKey)?.id, currentTargetLabel); const stateLabel = label ?? ({ idle: bilingual('离线', 'Offline'), connecting: bilingual('连接中', 'Connecting'), @@ -1213,6 +1218,7 @@ function markReady(message = bilingual('交互式 Shell 已就绪', 'Interactive }); startTimers(); updateConnectionStatus(messageTranslation(message)); + filePage?.setMessage(''); event(message, 'ready'); if (currentInitialCommand && !initialCommandSent) { initialCommandSent = true; @@ -1224,7 +1230,7 @@ function markReady(message = bilingual('交互式 Shell 已就绪', 'Interactive sendTerminalData(`${command}\r`); }, 120); } - terminal.focus(); + if (document.body.dataset.view === 'workspace') terminal.focus(); } function sendHostKeyDecision(accept: boolean): void { @@ -1611,7 +1617,7 @@ async function connect(): Promise { showFormError(validationError); return; } - dashboard?.openWorkspace(); + if (document.body.dataset.view !== 'files') dashboard?.openWorkspace(); if (location.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(location.hostname)) { showFormError(bilingual('发送 SSH 凭据前必须使用 HTTPS。', 'HTTPS is required before SSH credentials can be sent.')); return; @@ -1622,7 +1628,6 @@ async function connect(): Promise { const generation = ++connectGeneration; const abortController = new AbortController(); authorizationAbort = abortController; - setState('connecting'); ui.terminalEmpty.hidden = true; resetTerminalForConnection(terminal); fitTerminal(false); @@ -1632,11 +1637,13 @@ async function connect(): Promise { const username = ui.username.value.trim(); currentTargetKey = targetKey(host, port, username); currentTargetLabel = targetLabel(host, port, username); + setState('connecting'); const pinnedKey = ui.fingerprint.value.trim() || hostKeys[currentTargetKey] || ''; currentExpectedFingerprint = pinnedKey; currentRememberedFingerprint = ''; if (pinnedKey) ui.fingerprint.value = pinnedKey; - currentInitialCommand = ui.initialCommand.value; + // 文件管理不应在隐藏终端里执行用户的初始命令;保存的主机配置仍保持原样。 + currentInitialCommand = document.body.dataset.view === 'files' ? '' : ui.initialCommand.value; initialCommandSent = false; pendingHostKey = null; awaitingHostKeyDecision = false; @@ -2217,7 +2224,24 @@ async function initialize(): Promise { initializeCompatibilityAPI(); initMobileToolbar(); applyURLParameters(); + // 两个视图共用指纹确认与通知,不能随隐藏的终端容器一起消失。 + document.body.append(ui.hostKeyDialog, ui.toastRegion); + filePage = new FilePage(ui.fileManagerPanel, { + connect: async (host) => { + const loading = applyProfile(host); + const selectionGeneration = historyPasswordLoadGeneration; + await loading; + // 返回总览会作废凭据读取;即使用户立刻回到文件页,也不能启动已经取消的连接。 + if (selectionGeneration === historyPasswordLoadGeneration && document.body.dataset.view === 'files') await connect(); + }, + disconnect: () => disconnect(), + openTerminal: () => { + dashboard?.openWorkspace(); + if (connectionState === 'idle' || connectionState === 'error') setPanelOpen(true); + }, + }); dashboard = new Dashboard({ + files: filePage, refresh: async () => { profiles = await loadProfiles(); renderProfiles(); diff --git a/package-lock.json b/package-lock.json index ce0b892..c8f40dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^5.20260727.1", + "@playwright/test": "^1.63.0", "@types/node": "^22.20.4", "flag-icons": "^7.5.0", "smol-toml": "^1.8.0", @@ -1167,6 +1168,22 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -1823,6 +1840,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.23", "resolved": "https://mirrors.huaweicloud.com/repository/npm/postcss/-/postcss-8.5.23.tgz", diff --git a/package.json b/package.json index 2d7ac3b..d704082 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build:web": "vite build --config vite.config.ts", "typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.frontend.json --noEmit && tsc -p tsconfig.scripts.json --noEmit", "test": "node --test --experimental-strip-types tests/*.test.ts", + "test:browser": "playwright test", "check": "npm run typecheck && npm run test && wrangler deploy --dry-run --outdir .wrangler/build", "deploy:validate": "node --experimental-strip-types scripts/deploy.ts --validate-only", "deploy": "node --experimental-strip-types scripts/deploy.ts" @@ -24,6 +25,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^5.20260727.1", + "@playwright/test": "^1.63.0", "@types/node": "^22.20.4", "flag-icons": "^7.5.0", "smol-toml": "^1.8.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d3aa92a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/browser', + fullyParallel: true, + use: { + channel: process.env.PLAYWRIGHT_CHANNEL, + baseURL: 'http://127.0.0.1:5173', + locale: 'zh-CN', + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + projects: [ + { name: 'desktop', use: { viewport: { width: 1440, height: 1000 } } }, + { name: 'mobile', use: { viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true } }, + ], + webServer: { + command: 'npm run dev:web -- --host 127.0.0.1', + url: 'http://127.0.0.1:5173', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/tests/browser/file-fixture.ts b/tests/browser/file-fixture.ts new file mode 100644 index 0000000..f1029f7 --- /dev/null +++ b/tests/browser/file-fixture.ts @@ -0,0 +1,113 @@ +import type { Page, WebSocketRoute } from '@playwright/test'; + +const fingerprint = `SHA256:${'A'.repeat(43)}`; +const host = (id: string, name: string, address: string) => ({ + id, name, host: address, port: 22, username: 'root', group: '测试主机', + authMethod: 'password', initialCommand: 'echo should-not-run-in-files', + termType: 'xterm-256color', encoding: 'utf-8', fingerprint, + location: null, system: null, hasCredential: true, updatedAt: Date.now(), +}); +const entry = (name: string, type = 'file', size = 12) => ({ + name, type, size, mtime: 1_790_100_000, permissions: type === 'directory' ? '0755' : '0644', owner: 'root', group: 'root', +}); + +// 只在测试浏览器内模拟 API/协议,不访问真实主机、不增加生产认证绕过。 +export async function fileFixture(page: Page, options: { firstSeen?: boolean; credentialError?: boolean; noHosts?: boolean; holdUpload?: boolean } = {}) { + const hosts = options.noHosts ? [] : [host('alpha', 'Tokyo production', '192.0.2.10'), host('beta', 'Singapore backup', '192.0.2.20')]; + const directories = new Map([ + ['/', [entry('root', 'directory')]], + ['/root', [entry('backups', 'directory'), entry('deploy', 'directory'), entry('README.md'), entry('nginx.conf', 'file', 2480)]], + ['/root/backups', []], + ['/root/deploy', []], + ]); + const calls: Array> = []; + const sshSockets: WebSocketRoute[] = []; + const sftpSockets: WebSocketRoute[] = []; + const uploaded: Buffer[] = []; + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === '/api/auth/me') return route.fulfill({ json: { account: { username: 'Administrator' }, provider: 'cloudflare' } }); + if (path === '/api/session') return route.fulfill({ json: { ticket: 'test-ticket', sessionId: crypto.randomUUID() } }); + if (path.endsWith('/credentials')) { + return route.fulfill(options.credentialError + ? { status: 500, json: { error: '读取凭据失败,请重试。' } } + : { json: { password: 'test-only-password' } }); + } + if (path === '/api/hosts') return route.fulfill({ json: { hosts } }); + return route.fulfill({ json: { host: hosts.find((item) => path.includes(item.id)) ?? hosts[0] } }); + }); + await page.routeWebSocket('**/api/ssh?*', (ws) => { + sshSockets.push(ws); + const ready = () => { + ws.send(JSON.stringify({ type: 'ready' })); + ws.send(JSON.stringify({ type: 'sftp_attach', url: '/api/sftp?session=test&token=test' })); + }; + ws.onMessage((raw) => { + if (typeof raw !== 'string') { calls.push({ type: 'terminal-input', data: raw.toString() }); return; } + const message = JSON.parse(raw); + calls.push(message); + if (message.type === 'connect') { + if (options.firstSeen) { + // 返回变化指纹,确保仍需真实 UI 确认,而不是由测试直接放行。 + ws.send(JSON.stringify({ type: 'host_key', fingerprint: `SHA256:${'B'.repeat(43)}`, expectedFingerprint: fingerprint, keyType: 'ssh-ed25519', trusted: false })); + } else ready(); + } + if (message.type === 'host_key_decision' && message.accept) ready(); + }); + }); + await page.routeWebSocket('**/api/sftp?*', (ws) => { + sftpSockets.push(ws); + let upload: { requestId: string; path: string; size: number } | undefined; + const send = (data: object) => ws.send(JSON.stringify(data)); + const parent = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/'; + const name = (path: string) => path.slice(path.lastIndexOf('/') + 1); + ws.onMessage((raw) => { + if (typeof raw !== 'string') { + uploaded.push(Buffer.from(raw)); + send({ type: 'sftp_upload_progress', requestId: upload!.requestId, loaded: uploaded.reduce((sum, chunk) => sum + chunk.length, 0) }); + return; + } + const message = JSON.parse(raw); + calls.push(message); + const { type, requestId, path } = message; + if (type === 'sftp_init') send({ type: 'sftp_ready', cwd: '/root', version: 3 }); + if (type === 'sftp_list') { + if (path === '/forbidden') send({ type: 'sftp_error', requestId, message: 'Permission denied' }); + else send({ type: 'sftp_list_result', requestId, path, entries: directories.get(path) ?? [] }); + } + if (type === 'sftp_mkdir') { + directories.get(parent(path))!.push(entry(name(path), 'directory')); + directories.set(path, []); + } + if (type === 'sftp_rename') { + directories.get(parent(message.oldPath))!.find((item) => item.name === name(message.oldPath))!.name = name(message.newPath); + } + if (type === 'sftp_delete' || type === 'sftp_rmdir') { + directories.set(parent(path), directories.get(parent(path))!.filter((item) => item.name !== name(path))); + } + if (['sftp_mkdir', 'sftp_rename', 'sftp_delete', 'sftp_rmdir'].includes(type)) send({ type: `${type}_result`, requestId }); + if (type === 'sftp_upload_start') { + upload = message; + if (!options.holdUpload) send({ type: 'sftp_upload_ready', requestId }); + } + if (type === 'sftp_upload_end') { + directories.get(parent(upload!.path))!.push(entry(name(upload!.path), 'file', upload!.size)); + send({ type: 'sftp_upload_complete', requestId }); + } + if (type === 'sftp_download') { + const data = Buffer.from('hello files\n'); + send({ type: 'sftp_download_start', requestId, size: data.length }); + ws.send(data); + send({ type: 'sftp_download_done', requestId, size: data.length }); + } + }); + }); + await page.goto('/'); + await page.locator('#rail-files').click(); + return { hosts, calls, sshSockets, sftpSockets, uploaded }; +} + +export async function connectFiles(page: Page, id = 'alpha') { + await page.locator('#files-host').selectOption(id); + await page.locator('#files-connect').click(); +} diff --git a/tests/browser/files.spec.ts b/tests/browser/files.spec.ts new file mode 100644 index 0000000..49a3298 --- /dev/null +++ b/tests/browser/files.spec.ts @@ -0,0 +1,200 @@ +import { test, expect } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { connectFiles, fileFixture } from './file-fixture'; + +test('独立入口、空状态与移动端布局', async ({ page }, testInfo) => { + await fileFixture(page, { noHosts: true }); + await expect(page.locator('#files-heading')).toBeVisible(); + await expect(page.locator('#rail-files')).toHaveAttribute('aria-current', 'page'); + await expect(page.locator('#files-connect')).toBeDisabled(); + await expect(page.locator('#file-upload')).toBeDisabled(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: testInfo.outputPath('files-empty.png'), fullPage: true }); + await page.locator('#rail-overview').click(); + await expect(page.locator('#hosts-heading')).toBeVisible(); +}); + +test('连接、目录导航与共享终端会话不重复附着', async ({ page }, testInfo) => { + const fixture = await fileFixture(page); + await connectFiles(page); + await expect(page.locator('#file-manager-path')).toHaveValue('/root'); + await expect(page.locator('#file-table-body')).toContainText('README.md'); + await expect(page.locator('#files-host')).toBeDisabled(); + await expect(page.locator('#files-connection-state')).toHaveText('SSH 已连接'); + await expect(page.locator('.header-add')).toBeHidden(); + await page.locator('.files-footer').scrollIntoViewIfNeeded(); + await expect(page.locator('.files-footer')).toBeInViewport(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await expect(page.locator('#toast-region .toast')).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath('files-connected.png'), fullPage: true }); + await page.locator('#file-table-body tr').filter({ hasText: 'backups' }).dblclick(); + await expect(page.locator('#file-manager-path')).toHaveValue('/root/backups'); + await page.locator('#file-up').click(); + await expect(page.locator('#file-manager-path')).toHaveValue('/root'); + await page.locator('#files-terminal').click(); + await expect(page.locator('#terminal-card')).toBeVisible(); + await page.locator('#file-manager-tab').click(); + await expect(page.locator('#app #file-manager-panel')).toBeVisible(); + await page.getByRole('button', { name: '文件管理', exact: true }).click(); + await expect(page.locator('.files-page #file-manager-panel')).toBeVisible(); + await expect(page.locator('#file-manager-path')).toHaveValue('/root'); + expect(fixture.sshSockets).toHaveLength(1); + expect(fixture.sftpSockets).toHaveLength(1); + expect(fixture.calls.filter((call) => call.type === 'terminal-input')).toHaveLength(0); + await page.locator('#files-terminal').click(); + await expect(page.locator('#app #file-manager-panel')).toBeVisible(); + await expect(page.locator('#file-manager-tab')).toHaveAttribute('aria-selected', 'true'); +}); + +test('复用新建文件夹、重命名和删除协议', async ({ page }) => { + const fixture = await fileFixture(page); + await connectFiles(page); + await expect(page.locator('#file-mkdir')).toBeEnabled(); + page.once('dialog', (dialog) => dialog.accept('test-folder')); + await page.locator('#file-mkdir').click(); + const folder = page.locator('#file-table-body tr').filter({ hasText: 'test-folder' }); + await expect(folder).toBeVisible(); + await folder.click(); + page.once('dialog', (dialog) => dialog.accept('renamed-folder')); + await page.locator('#file-rename').click(); + const renamed = page.locator('#file-table-body tr').filter({ hasText: 'renamed-folder' }); + await expect(renamed).toBeVisible(); + await renamed.click(); + page.once('dialog', (dialog) => dialog.dismiss()); + await page.locator('#file-delete').click(); + await expect(renamed).toBeVisible(); + page.once('dialog', (dialog) => dialog.accept()); + await page.locator('#file-delete').click(); + await expect(renamed).toHaveCount(0); + await page.locator('#file-table-body tr').filter({ hasText: 'README.md' }).click(); + page.once('dialog', (dialog) => dialog.accept()); + await page.locator('#file-delete').click(); + await expect(page.locator('#file-table-body')).not.toContainText('README.md'); + expect(fixture.calls).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'sftp_mkdir', path: '/root/test-folder' }), + expect.objectContaining({ type: 'sftp_rename', oldPath: '/root/test-folder', newPath: '/root/renamed-folder' }), + expect.objectContaining({ type: 'sftp_rmdir', path: '/root/renamed-folder' }), + expect.objectContaining({ type: 'sftp_delete', path: '/root/README.md' }), + ])); +}); + +test('上传与下载校验实际字节', async ({ page }) => { + const fixture = await fileFixture(page); + await connectFiles(page); + await expect(page.locator('#file-upload')).toBeEnabled(); + const payload = Buffer.from('file manager upload regression\n'); + await page.locator('#file-upload-input').setInputFiles({ name: 'upload.txt', mimeType: 'text/plain', buffer: payload }); + await expect(page.locator('#file-table-body')).toContainText('upload.txt'); + expect(Buffer.concat(fixture.uploaded)).toEqual(payload); + await page.locator('#file-table-body tr').filter({ hasText: 'README.md' }).click(); + const downloading = page.waitForEvent('download'); + await page.locator('#file-download').click(); + const download = await downloading; + expect(download.suggestedFilename()).toBe('README.md'); + expect(await readFile((await download.path())!, 'utf8')).toBe('hello files\n'); +}); + +test('更换主机清理旧会话,文件页继续可操作', async ({ page }) => { + const fixture = await fileFixture(page); + await connectFiles(page); + await expect(page.locator('#file-upload')).toBeEnabled(); + await page.locator('#files-connect').click(); + await expect(page.locator('#file-upload')).toBeDisabled(); + await expect(page.locator('#files-host')).toBeEnabled(); + await connectFiles(page, 'beta'); + await expect(page.locator('#file-upload')).toBeEnabled(); + expect(fixture.calls.filter((call) => call.type === 'connect').map((call) => call.host)).toEqual(['192.0.2.10', '192.0.2.20']); + expect(fixture.calls.filter((call) => call.type === 'sftp_close')).toHaveLength(1); +}); + +test('文件页仍需指纹确认', async ({ page }) => { + const fixture = await fileFixture(page, { firstSeen: true }); + await connectFiles(page); + await expect(page.locator('#host-key-dialog')).toBeVisible(); + await expect(page.locator('#file-upload')).toBeDisabled(); + expect(fixture.sftpSockets).toHaveLength(0); + await page.locator('#accept-host-key').click(); + await expect(page.locator('#file-upload')).toBeEnabled(); +}); + +test('凭据和目录错误显示在文件页', async ({ page }) => { + await fileFixture(page, { credentialError: true }); + await connectFiles(page); + await expect(page.locator('#files-notice')).toContainText('读取凭据失败'); + await expect(page.locator('#files-connect')).toBeEnabled(); +}); + +test('目录权限错误可恢复', async ({ page }) => { + await fileFixture(page); + await connectFiles(page); + await expect(page.locator('#file-upload')).toBeEnabled(); + await page.locator('#file-manager-path').fill('/forbidden'); + await page.locator('#file-manager-path').press('Enter'); + await expect(page.locator('#file-manager-error')).toBeVisible(); + await page.locator('#file-home').click(); + await expect(page.locator('#file-table-body')).toContainText('README.md'); + await expect(page.locator('#file-manager-error')).toBeHidden(); +}); + +test('传输时离开需确认,切换终端不打断传输', async ({ page }) => { + const fixture = await fileFixture(page, { holdUpload: true }); + await connectFiles(page); + await expect(page.locator('#file-upload')).toBeEnabled(); + await page.locator('#file-upload-input').setInputFiles({ name: 'pending.txt', mimeType: 'text/plain', buffer: Buffer.from('pending') }); + await expect(page.locator('#file-manager-progress')).toBeVisible(); + page.once('dialog', (dialog) => dialog.dismiss()); + await page.locator('#rail-overview').click(); + await expect(page.locator('#files-heading')).toBeVisible(); + await page.locator('#files-terminal').click(); + await page.getByRole('button', { name: '文件管理', exact: true }).click(); + await expect(page.locator('#file-manager-progress')).toBeVisible(); + expect(fixture.sftpSockets).toHaveLength(1); + page.once('dialog', (dialog) => dialog.accept()); + await page.locator('#files-connect').click(); + await expect(page.locator('#file-manager-progress')).toBeHidden(); +}); + +test('读取凭据期间禁止切换终端,避免无反馈取消连接', async ({ page }) => { + await fileFixture(page); + let finish!: () => void; + const pending = new Promise((resolve) => { finish = resolve; }); + await page.route('**/api/hosts/alpha/credentials', async (route) => { + await pending; + await route.fulfill({ json: { password: 'test-only' } }); + }); + await connectFiles(page); + await expect(page.locator('#files-connect')).toHaveText('读取凭据中…'); + await expect(page.locator('#files-terminal')).toBeDisabled(); + finish(); + await expect(page.locator('#file-upload')).toBeEnabled(); + await expect(page.locator('#files-terminal')).toBeEnabled(); +}); + +test('离开再返回文件页,迟到的凭据不会启动已取消会话', async ({ page }) => { + const fixture = await fileFixture(page); + let finish!: () => void; + const pending = new Promise((resolve) => { finish = resolve; }); + await page.route('**/api/hosts/alpha/credentials', async (route) => { + await pending; + await route.fulfill({ json: { password: 'test-only' } }); + }); + await connectFiles(page); + await expect(page.locator('#files-connect')).toHaveText('读取凭据中…'); + await page.locator('#rail-overview').click(); + await expect(page.locator('#hosts-heading')).toBeVisible(); + await page.locator('#rail-files').click(); + finish(); + await expect(page.locator('#files-connect')).toHaveText('连接主机'); + expect(fixture.sshSockets).toHaveLength(0); +}); + +test('会话授权失败后可重试,文件操作保持禁用', async ({ page }) => { + await fileFixture(page); + await page.route('**/api/session', (route) => route.fulfill({ status: 403, json: { error: '会话授权失败,请重新登录。' } })); + await connectFiles(page); + await expect(page.locator('#files-notice')).toContainText('会话授权失败'); + await expect(page.locator('#files-connection-state')).toHaveText('连接失败'); + await expect(page.locator('#files-connect')).toBeEnabled(); + await expect(page.locator('#file-upload')).toBeDisabled(); + await expect(page.locator('#files-host')).toBeEnabled(); +});