diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fcd52e874..48de39cc92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- [Fixed] Long speaker notes are now scrollable inside the presenter view instead of being clipped when notes push the layout past the viewport. (#6271) +- [Fixed] Owner sharing overlays, retained links, and publication progress now match the final sharing design. Published-link popovers retain comment-sync status text and retry actions without a bordered card; a previously published link stays copyable when workspace access is lost. - [Changed] Retired the official Nix flake, Home Manager/NixOS modules, and lockfile-coupled Nix CI maintenance. OpenDesign still discovers agent CLIs installed through Nix profiles on NixOS and nix-darwin hosts. ## [0.9.0] - 2026-05-29 diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index a0d14d138d..5f69b21f96 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -7198,6 +7198,8 @@ async function postImportFolderToDaemon(base, body, baseDir, workspaceHeaders = function printProjectShareHelp() { console.log(`Usage: + od project share preflight --path [--json] + Inspect included and missing references before publishing. od project share publish --path [--json] Publish a project file using the same endpoint as the UI. od project share resume --path [--json] @@ -7212,7 +7214,7 @@ function printProjectShareHelp() { od project share retry-stop --path --slug [--json] Retry one persisted stop, including after project deletion. -Supported actions: publish, resume, get, status, stop, and retry-stop. +Supported actions: preflight, publish, resume, get, status, stop, and retry-stop. Common options: --daemon-url OpenDesign daemon HTTP base. @@ -7228,15 +7230,15 @@ async function runProjectShare(args) { process.exit(args.length === 0 ? 2 : 0); } const [requestedAction, ...rest] = args; - // Resume intentionally uses the same owner-verified lifecycle endpoint as the UI. - const action = requestedAction === 'status' ? 'get' : requestedAction === 'resume' ? 'publish' : requestedAction; + // Resume shares the owner-verified endpoint but carries a distinct no-upload intent. + const action = requestedAction === 'status' ? 'get' : requestedAction; const stringFlags = new Set(['path', 'daemon-url', 'workspace', 'workspace-member', ...(['stop', 'retry-stop'].includes(action) ? ['slug'] : [])]); let flags; try { flags = parseFlags(rest, { string: stringFlags, boolean: new Set(['json']) }); } catch { - console.error('Usage: od project share --path [--json] (stop requires --slug ). See --help for accepted flags.'); + console.error('Usage: od project share --path [--json] (stop requires --slug ). See --help for accepted flags.'); process.exit(2); } const positional = positionalArgs(rest, stringFlags); @@ -7246,8 +7248,8 @@ async function runProjectShare(args) { const missingFlagValue = [...stringFlags].some((key) => typeof flags[key] === 'string' && (!flags[key].trim() || flags[key].startsWith('--'))); const slug = typeof flags.slug === 'string' ? flags.slug.trim() : ''; - if (!['publish', 'get', 'stop', 'retry-stop'].includes(action) || positional.length !== 1 || !id?.trim() || (!filePath && !projectStatus) || missingFlagValue || (['stop', 'retry-stop'].includes(action) && !slug)) { - console.error('Usage: od project share --path [--json] (stop requires --slug )'); + if (!['publish', 'preflight', 'resume', 'get', 'stop', 'retry-stop'].includes(action) || positional.length !== 1 || !id?.trim() || (!filePath && !projectStatus) || missingFlagValue || (['stop', 'retry-stop'].includes(action) && !slug)) { + console.error('Usage: od project share --path [--json] (stop requires --slug )'); process.exit(2); } // Validate before discovery; malformed invocations must not contact a daemon. @@ -7256,12 +7258,14 @@ async function runProjectShare(args) { let resp; try { resp = await fetch( - projectStatus ? `${base}/api/projects/${encodeURIComponent(id)}/share-state` : action === 'retry-stop' ? `${base}/api/public-file-stops/retry` : `${base}/api/projects/${encodeURIComponent(id)}/files/${encodeURIComponent(filePath)}/publish-public`, + projectStatus ? `${base}/api/projects/${encodeURIComponent(id)}/share-state` : action === 'retry-stop' ? `${base}/api/public-file-stops/retry` : `${base}/api/projects/${encodeURIComponent(id)}/files/${encodeURIComponent(filePath)}/${action === 'preflight' ? 'share-plan' : 'publish-public'}`, action === 'retry-stop' ? { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify({ projectId: id, filePath, slug }) } : action === 'stop' ? { method: 'DELETE', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify({ slug }) } - : { method: action === 'publish' ? 'POST' : 'GET', headers }, + : action === 'resume' + ? { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'resume' }) } + : { method: ['publish', 'preflight'].includes(action) ? 'POST' : 'GET', headers }, ); } catch { // Do not echo transport exceptions, which can contain URLs or credentials. @@ -7271,6 +7275,12 @@ async function runProjectShare(args) { const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); if (projectStatus) return console.log(JSON.stringify(data, null, 2)); + if (action === 'preflight') { + console.log(`Share plan: ${data.fileCount} files, ${data.totalBytes} bytes.`); + for (const item of data.exclusions ?? []) console.log(`Not included (${item.reason}): ${item.path}`); + if (!(data.exclusions ?? []).length) console.log('No missing or invalid references.'); + return; + } if (['stop', 'retry-stop'].includes(action)) return console.log('Sharing stopped.'); if (data.status === 'binding_pending') { return console.log(`Content published; binding pending.${data.link?.status === 'unavailable' ? ' Link temporarily unavailable.' : ''}`); @@ -7304,6 +7314,8 @@ async function runProject(args) { Restore the daemon-selected default scenario with a snapshot CAS guard. od project delete Delete a project. + od project share preflight --path [--json] + Inspect included and missing references before publishing. od project share publish --path [--json] Publish a project file. od project share resume --path [--json] diff --git a/apps/daemon/src/collab/collab-cloud-service.ts b/apps/daemon/src/collab/collab-cloud-service.ts index 7d1684acfc..b6062d98ff 100644 --- a/apps/daemon/src/collab/collab-cloud-service.ts +++ b/apps/daemon/src/collab/collab-cloud-service.ts @@ -8,146 +8,153 @@ // only constructed when OD_COLLAB_CLOUD_URL is set (see createCollabCloudClientFromEnv), // so an unconfigured daemon never even reaches here. -import { SHARE_COMMENT_TERMINAL_REJECTION } from '@open-design/contracts'; +import { SHARE_COMMENT_TERMINAL_REJECTION } from "@open-design/contracts"; import type { - CollabCloudComment, - CollabCloudMemberDirectoryEntry, - PreviewComment, - WorkspaceCollabContext, -} from '@open-design/contracts'; -import { CollabCloudError, type CollabCloudClient } from '../integrations/collab-cloud.js'; -import type { SyncedCommentMergeResult } from '../db.js'; -import type { WorkspaceContextProvider } from './workspace-context.js'; -import type { CommentRelayScope } from './comment-relay-scope.js'; + CollabCloudComment, + CollabCloudMemberDirectoryEntry, + PreviewComment, + WorkspaceCollabContext, +} from "@open-design/contracts"; +import { + CollabCloudError, + type CollabCloudClient, +} from "../integrations/collab-cloud.js"; +import type { SyncedCommentMergeResult } from "../db.js"; +import type { WorkspaceContextProvider } from "./workspace-context.js"; +import type { CommentRelayScope } from "./comment-relay-scope.js"; import type { - CommentRelayOutboxIdentity, - CommentRelayOutboxRecord, - CommentRelayOutboxStore, -} from './comment-relay-outbox.js'; + CommentRelayOutboxIdentity, + CommentRelayOutboxRecord, + CommentRelayOutboxStore, +} from "./comment-relay-outbox.js"; /** The daemon-local seams the service needs; injected so this file stays free of * SQLite and the poller is unit-testable with fakes. */ export interface CollabCloudServiceDeps { - client: CollabCloudClient; - /** - * Legacy construction seam retained for compatibility with isolated callers. - * Project operations never read it: ambient active-workspace state is not - * data-plane authority. - */ - workspaceContext?: WorkspaceContextProvider; - /** Local project ids to poll for inbound comments. */ - listProjectIds: () => string[]; - /** Resolve the exact persisted + directory-verified scope for one project. */ - resolveProjectWorkspaceContext?: ( - projectId: string, - options?: { fresh?: boolean }, - ) => Promise; - /** - * Resolve one fresh directory authority for a durable relay batch. The - * returned context must still match the persisted identity; the service - * verifies that before any catalog read or push. - */ - resolveCommentRelayWorkspaceContext?: ( - identity: CommentRelayOutboxIdentity, - options: { fresh: true }, - ) => Promise; - /** Cheap local binding witness applied per queued record in a batch. */ - validateCommentRelayProjectBinding?: (record: CommentRelayOutboxRecord) => boolean; - /** Separate creator-scoped eligibility for active public personal projects. */ - commentRelayScope?: ( - projectId: string, - filePath: string, - context: WorkspaceCollabContext, - ) => CommentRelayScope | null; - /** Exact active personal-publication files for this project's persisted creator. - * This must fail closed and must not consult the member directory. */ - listPersonalCommentRelayFilePaths?: ( - projectId: string, - context: WorkspaceCollabContext, - ) => ReadonlySet; - /** - * Where a comment we already store lives, by project AND comment id. - * - * A deletion arriving from the cloud carries no anchor: there is nothing left - * to point at. The personal path filters incoming records by `filePath`, so a - * tombstone matches nothing and is dropped — while the cursor still advances - * past it. The deletion is then unreachable forever, and the local copy keeps - * a comment the author deleted on the web. - * - * Resolving the stored record's own `filePath` is what lets a tombstone be - * judged by the same publication rules as the comment it deletes, instead of - * by an anchor it cannot have. - * - * Contract, and each clause is load-bearing: - * - BOTH ids are required. Matching on comment id alone would let one - * project's deletion reach another project's row. - * - `found: false` means the row is genuinely absent — a safe no-op. - * - A failed lookup MUST throw. It must never be reported as absence: that - * would turn "the database did not answer" into "there is nothing to - * delete", and the batch would be acknowledged with the deletion lost. - * - Only the stored path is returned. The comment's own content stays out of - * this seam; the caller is deciding eligibility, not reading the comment. - */ - resolveStoredCommentLocation?: ( - projectId: string, - commentId: string, - ) => { found: false } | { found: true; filePath: string | null }; - /** Resolve a server-asserted publication identity to a currently published local path. - * null is an unknown/stopped alias; errors retain the batch cursor for retry. - * This does not authorize a merge: existing identity/file checks still apply. - */ - resolvePublishedCommentSourcePath?: (input: { - projectId: string; publicationSlug: string; publishedPath: string; - context: WorkspaceCollabContext; - }) => string | null; - /** Local binding witness captured synchronously when the mutation commits. */ - resolveLocalProjectRelayBinding?: (projectId: string) => { - workspaceId: string; - ownerMemberId: string | null; - } | null; - /** Fresh remote catalog witness checked immediately before each relay push. */ - resolveRemoteProjectOwnerMemberId?: ( - projectId: string, - context: WorkspaceCollabContext, - ) => Promise; - /** - * One uncached Team catalog snapshot for a durable relay batch. Multiple - * owners for the same project id are retained and matched exactly. - */ - listRemoteProjectRelayBindings?: ( - context: WorkspaceCollabContext, - ) => Promise>; - /** - * Resolve a LOCAL conversation id to re-home synced comments onto (conversation - * ids do not cross daemons, and preview_comments has a conversation FK). Null - * when the project has no local conversation yet — the poller then skips it. - */ - resolveLocalConversationId: (projectId: string) => string | null; - /** - * Merge one pulled comment into local storage, idempotently by comment id. - * Acknowledge changed or safely unchanged state; throw on persistence failure. - */ - mergeComment: (input: { - projectId: string; - conversationId: string; - comment: CollabCloudComment; - }) => SyncedCommentMergeResult; - /** Poll cadence; defaults to the spec's foreground 5s (§D4.5). */ - pollIntervalMs?: number; - /** Durable outbound Team-comment queue. Omitted by isolated/local callers. */ - commentOutbox?: CommentRelayOutboxStore; - /** Called after a queued create/edit receives its authoritative relay seq. */ - onCommentPushed?: (input: { - projectId: string; - commentId: string; - seq: number; - memberId: string; - authorKey?: string; - }) => void; - now?: () => number; - retryDelayMs?: (attemptCount: number) => number; - onError?: (error: unknown) => void; - onMerged?: (input: { projectId: string; inserted: number }) => void; + client: CollabCloudClient; + /** + * Legacy construction seam retained for compatibility with isolated callers. + * Project operations never read it: ambient active-workspace state is not + * data-plane authority. + */ + workspaceContext?: WorkspaceContextProvider; + /** Local project ids to poll for inbound comments. */ + listProjectIds: () => string[]; + /** Resolve the exact persisted + directory-verified scope for one project. */ + resolveProjectWorkspaceContext?: ( + projectId: string, + options?: { fresh?: boolean }, + ) => Promise; + /** + * Resolve one fresh directory authority for a durable relay batch. The + * returned context must still match the persisted identity; the service + * verifies that before any catalog read or push. + */ + resolveCommentRelayWorkspaceContext?: ( + identity: CommentRelayOutboxIdentity, + options: { fresh: true }, + ) => Promise; + /** Cheap local binding witness applied per queued record in a batch. */ + validateCommentRelayProjectBinding?: ( + record: CommentRelayOutboxRecord, + ) => boolean; + /** Separate creator-scoped eligibility for active public personal projects. */ + commentRelayScope?: ( + projectId: string, + filePath: string, + context: WorkspaceCollabContext, + ) => CommentRelayScope | null; + /** Exact active personal-publication files for this project's persisted creator. + * This must fail closed and must not consult the member directory. */ + listPersonalCommentRelayFilePaths?: ( + projectId: string, + context: WorkspaceCollabContext, + ) => ReadonlySet; + /** + * Where a comment we already store lives, by project AND comment id. + * + * A deletion arriving from the cloud carries no anchor: there is nothing left + * to point at. The personal path filters incoming records by `filePath`, so a + * tombstone matches nothing and is dropped — while the cursor still advances + * past it. The deletion is then unreachable forever, and the local copy keeps + * a comment the author deleted on the web. + * + * Resolving the stored record's own `filePath` is what lets a tombstone be + * judged by the same publication rules as the comment it deletes, instead of + * by an anchor it cannot have. + * + * Contract, and each clause is load-bearing: + * - BOTH ids are required. Matching on comment id alone would let one + * project's deletion reach another project's row. + * - `found: false` means the row is genuinely absent — a safe no-op. + * - A failed lookup MUST throw. It must never be reported as absence: that + * would turn "the database did not answer" into "there is nothing to + * delete", and the batch would be acknowledged with the deletion lost. + * - Only the stored path is returned. The comment's own content stays out of + * this seam; the caller is deciding eligibility, not reading the comment. + */ + resolveStoredCommentLocation?: ( + projectId: string, + commentId: string, + ) => { found: false } | { found: true; filePath: string | null }; + /** Resolve a server-asserted publication identity to a currently published local path. + * null is an unknown/stopped alias; errors retain the batch cursor for retry. + * This does not authorize a merge: existing identity/file checks still apply. + */ + resolvePublishedCommentSourcePath?: (input: { + projectId: string; + publicationSlug: string; + publishedPath: string; + context: WorkspaceCollabContext; + }) => string | null; + /** Local binding witness captured synchronously when the mutation commits. */ + resolveLocalProjectRelayBinding?: (projectId: string) => { + workspaceId: string; + ownerMemberId: string | null; + } | null; + /** Fresh remote catalog witness checked immediately before each relay push. */ + resolveRemoteProjectOwnerMemberId?: ( + projectId: string, + context: WorkspaceCollabContext, + ) => Promise; + /** + * One uncached Team catalog snapshot for a durable relay batch. Multiple + * owners for the same project id are retained and matched exactly. + */ + listRemoteProjectRelayBindings?: ( + context: WorkspaceCollabContext, + ) => Promise>; + /** + * Resolve a LOCAL conversation id to re-home synced comments onto (conversation + * ids do not cross daemons, and preview_comments has a conversation FK). Null + * when the project has no local conversation yet — the poller then skips it. + */ + resolveLocalConversationId: (projectId: string) => string | null; + /** + * Merge one pulled comment into local storage, idempotently by comment id. + * Acknowledge changed or safely unchanged state; throw on persistence failure. + */ + mergeComment: (input: { + projectId: string; + conversationId: string; + comment: CollabCloudComment; + }) => SyncedCommentMergeResult; + /** Poll cadence; defaults to the spec's foreground 5s (§D4.5). */ + pollIntervalMs?: number; + /** Durable outbound Team-comment queue. Omitted by isolated/local callers. */ + commentOutbox?: CommentRelayOutboxStore; + /** Called after a queued create/edit receives its authoritative relay seq. */ + onCommentPushed?: (input: { + projectId: string; + commentId: string; + seq: number; + memberId: string; + authorKey?: string; + }) => void; + now?: () => number; + retryDelayMs?: (attemptCount: number) => number; + onError?: (error: unknown) => void; + onMerged?: (input: { projectId: string; inserted: number }) => void; } const DEFAULT_POLL_INTERVAL_MS = 5_000; @@ -161,849 +168,999 @@ const COMMENT_OUTBOX_PUSH_CONCURRENCY = 4; * the relay owner is never misrepresented as its author. */ export function previewCommentToCloud( - comment: PreviewComment, - _fallbackMemberId: string, + comment: PreviewComment, + _fallbackMemberId: string, ): CollabCloudComment { - const cloud: CollabCloudComment = { - id: comment.id, - projectId: comment.projectId, - conversationId: comment.conversationId, - // A relay owner is not the author. Keep the legacy required wire field - // empty when an external/authorless comment has no workspace member. - memberId: comment.authorKind === 'user' ? '' : comment.authorMemberId ?? '', - seq: 0, - note: comment.note, - filePath: comment.filePath, - elementId: comment.elementId, - selector: comment.selector, - label: comment.label, - text: comment.text, - htmlHint: comment.htmlHint, - position: comment.position, - status: comment.status, - createdAt: comment.createdAt, - updatedAt: comment.updatedAt, - }; - // Copy optional fields only when present (exactOptionalPropertyTypes-safe). - if (comment.style !== undefined) cloud.style = comment.style; - if (comment.selectionKind !== undefined) cloud.selectionKind = comment.selectionKind; - if (comment.memberCount !== undefined) cloud.memberCount = comment.memberCount; - if (comment.podMembers !== undefined) cloud.podMembers = comment.podMembers; - if (comment.slideIndex !== undefined) cloud.slideIndex = comment.slideIndex; - if (comment.attachments !== undefined) cloud.attachments = comment.attachments; - if (comment.anchorState !== undefined) cloud.anchorState = comment.anchorState; - if (comment.anchoredVersion !== undefined) cloud.anchoredVersion = comment.anchoredVersion; - if (comment.lastGoodPosition !== undefined) cloud.lastGoodPosition = comment.lastGoodPosition; - if (comment.authorKind !== undefined) cloud.authorKind = comment.authorKind; - if (comment.authorAppUserId !== undefined) cloud.authorAppUserId = comment.authorAppUserId; - if (comment.authorDisplayName !== undefined) cloud.authorDisplayName = comment.authorDisplayName; - if (comment.authorKey !== undefined) cloud.authorKey = comment.authorKey; - return cloud; + const cloud: CollabCloudComment = { + id: comment.id, + projectId: comment.projectId, + conversationId: comment.conversationId, + // A relay owner is not the author. Keep the legacy required wire field + // empty when an external/authorless comment has no workspace member. + memberId: + comment.authorKind === "user" ? "" : (comment.authorMemberId ?? ""), + seq: 0, + note: comment.note, + filePath: comment.filePath, + elementId: comment.elementId, + selector: comment.selector, + label: comment.label, + text: comment.text, + htmlHint: comment.htmlHint, + position: comment.position, + status: comment.status, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + }; + // Copy optional fields only when present (exactOptionalPropertyTypes-safe). + if (comment.style !== undefined) cloud.style = comment.style; + if (comment.selectionKind !== undefined) + cloud.selectionKind = comment.selectionKind; + if (comment.memberCount !== undefined) + cloud.memberCount = comment.memberCount; + if (comment.podMembers !== undefined) cloud.podMembers = comment.podMembers; + if (comment.slideIndex !== undefined) cloud.slideIndex = comment.slideIndex; + if (comment.attachments !== undefined) + cloud.attachments = comment.attachments; + if (comment.anchorState !== undefined) + cloud.anchorState = comment.anchorState; + if (comment.anchoredVersion !== undefined) + cloud.anchoredVersion = comment.anchoredVersion; + if (comment.lastGoodPosition !== undefined) + cloud.lastGoodPosition = comment.lastGoodPosition; + if (comment.authorKind !== undefined) cloud.authorKind = comment.authorKind; + if (comment.authorAppUserId !== undefined) + cloud.authorAppUserId = comment.authorAppUserId; + if (comment.authorDisplayName !== undefined) + cloud.authorDisplayName = comment.authorDisplayName; + if (comment.authorKey !== undefined) cloud.authorKey = comment.authorKey; + return cloud; } export interface CollabCloudService { - /** PUT the current member's directory entry (best-effort; no-op off-team). */ - registerSelf(context?: WorkspaceCollabContext): Promise; - /** - * Push a created OR edited comment to the cloud (best-effort; no-op off-team). - * The relay upserts by id and receivers apply the newest by `updatedAt`, so the - * same call carries both the initial create and any later edit/status change. - * - * Resolves with the cloud-assigned `seq` for THIS push (or `null` off-team / - * on failure) so the caller can reconcile a new comment's provisional - * `pin_seq` — see `confirmPreviewCommentPinSeq` in db.ts and the - * recvq5BVsolIxi design note above `previewCommentToCloud`. The value is - * safe to feed into that reconciliation from EITHER a create or an edit - * push: the guard there only ever applies once per comment, so whichever - * push resolves first (in practice almost always the create) wins and a - * later resolution is a no-op. - */ - pushComment( - comment: PreviewComment, - context: WorkspaceCollabContext, - ): Promise<{ seq: number } | null>; - /** - * Push a delete as a tombstone (best-effort; no-op off-team). Receivers remove - * the comment by id. Stamps a fresh `updatedAt` so the tombstone is not treated - * as a stale edit if it races an in-flight update. - */ - pushCommentDeletion( - comment: PreviewComment, - context: WorkspaceCollabContext, - ): Promise; - /** Persist a create/edit for asynchronous, restart-safe relay delivery. */ - enqueueComment( - comment: PreviewComment, - context: WorkspaceCollabContext, - ): boolean; - /** Persist a delete tombstone for asynchronous, restart-safe delivery. */ - enqueueCommentDeletion( - comment: PreviewComment, - context: WorkspaceCollabContext, - ): boolean; - /** Drain due durable deliveries; exposed for deterministic tests/catch-up. */ - flushPendingComments(): Promise; - /** The explicitly scoped team's member directory (empty only off-team). */ - listMembers( - context: WorkspaceCollabContext, - ): Promise; - /** Resolve one member id to its directory entry, or null. */ - resolveMember( - memberId: string, - context: WorkspaceCollabContext, - ): Promise; - /** Run one poll cycle (register + pull + merge across all local projects). */ - pollOnce(): Promise; - /** - * Pull + merge ONE project's comments now, regardless of whether it has a - * live events subscriber. This is the hub push-channel consumer: a - * `comment-changed` dirty mark must be redeemable even when the project is - * not in `listProjectIds()` (the poll loop's open-projects scope) — the - * poll loop otherwise never covers it and the mark would be consumed for - * nothing. Errors land on `onError`; never throws. - * - * Resolves `true` only when a pull actually ran against the relay for this - * project (a legitimately-empty/not-modified result still counts). Resolves - * `false` when it no-oped (no team identity yet, no local conversation to - * merge into) or the pull failed — the caller must then treat its consumed - * dirty mark as UNREDEEMED and restore it, otherwise a transient miss - * silently loses the one signal a single comment ever gets. - */ - pullProject( - projectId: string, - context: WorkspaceCollabContext, - ): Promise; - /** Last successfully merged cursor for this exact publication/principal scope. - * Null after restart/before pull; never manufacture cursor zero for align. */ - readMergedCommentCursor(projectId: string, context: WorkspaceCollabContext): number | null; - /** Start the background poller. */ - start(): void; - /** Stop the poller. */ - dispose(): void; + /** PUT the current member's directory entry (best-effort; no-op off-team). */ + registerSelf(context?: WorkspaceCollabContext): Promise; + /** + * Push a created OR edited comment to the cloud (best-effort; no-op off-team). + * The relay upserts by id and receivers apply the newest by `updatedAt`, so the + * same call carries both the initial create and any later edit/status change. + * + * Resolves with the cloud-assigned `seq` for THIS push (or `null` off-team / + * on failure) so the caller can reconcile a new comment's provisional + * `pin_seq` — see `confirmPreviewCommentPinSeq` in db.ts and the + * recvq5BVsolIxi design note above `previewCommentToCloud`. The value is + * safe to feed into that reconciliation from EITHER a create or an edit + * push: the guard there only ever applies once per comment, so whichever + * push resolves first (in practice almost always the create) wins and a + * later resolution is a no-op. + */ + pushComment( + comment: PreviewComment, + context: WorkspaceCollabContext, + ): Promise<{ seq: number } | null>; + /** + * Push a delete as a tombstone (best-effort; no-op off-team). Receivers remove + * the comment by id. Stamps a fresh `updatedAt` so the tombstone is not treated + * as a stale edit if it races an in-flight update. + */ + pushCommentDeletion( + comment: PreviewComment, + context: WorkspaceCollabContext, + ): Promise; + /** Persist a create/edit for asynchronous, restart-safe relay delivery. */ + enqueueComment( + comment: PreviewComment, + context: WorkspaceCollabContext, + ): boolean; + /** Persist a delete tombstone for asynchronous, restart-safe delivery. */ + enqueueCommentDeletion( + comment: PreviewComment, + context: WorkspaceCollabContext, + ): boolean; + /** Drain due durable deliveries; exposed for deterministic tests/catch-up. */ + flushPendingComments(): Promise; + /** The explicitly scoped team's member directory (empty only off-team). */ + listMembers( + context: WorkspaceCollabContext, + ): Promise; + /** Resolve one member id to its directory entry, or null. */ + resolveMember( + memberId: string, + context: WorkspaceCollabContext, + ): Promise; + /** Run one poll cycle (register + pull + merge across all local projects). */ + pollOnce(): Promise; + /** + * Pull + merge ONE project's comments now, regardless of whether it has a + * live events subscriber. This is the hub push-channel consumer: a + * `comment-changed` dirty mark must be redeemable even when the project is + * not in `listProjectIds()` (the poll loop's open-projects scope) — the + * poll loop otherwise never covers it and the mark would be consumed for + * nothing. Errors land on `onError`; never throws. + * + * Resolves `true` only when a pull actually ran against the relay for this + * project (a legitimately-empty/not-modified result still counts). Resolves + * `false` when it no-oped (no team identity yet, no local conversation to + * merge into) or the pull failed — the caller must then treat its consumed + * dirty mark as UNREDEEMED and restore it, otherwise a transient miss + * silently loses the one signal a single comment ever gets. + */ + pullProject( + projectId: string, + context: WorkspaceCollabContext, + ): Promise; + /** Last successfully merged cursor for this exact publication/principal scope. + * Null after restart/before pull; never manufacture cursor zero for align. */ + readMergedCommentCursor( + projectId: string, + context: WorkspaceCollabContext, + ): number | null; + /** Start the background poller. */ + start(): void; + /** Stop the poller. */ + dispose(): void; } -export function createCollabCloudService(deps: CollabCloudServiceDeps): CollabCloudService { - const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; - const now = deps.now ?? Date.now; - const retryDelayMs = deps.retryDelayMs ?? ((attemptCount: number) => - Math.min(30_000, 1_000 * (2 ** Math.min(Math.max(0, attemptCount), 5)))); - // Per-project pull cursor + last ETag, so each poll only fetches new comments - // and a 304 costs nothing. - const cursors = new Map(); - const etags = new Map(); - const inFlightPulls = new Map>(); - let timer: NodeJS.Timeout | null = null; - let running = false; - let outboxRunning = false; - let outboxRerunRequested = false; - let started = false; - // The identity we last pushed to the member directory. Re-registering only - // when this changes keeps `pollOnce` from spawning a `vela member register` - // process on every 5s tick (see pollOnce). - let lastRegisteredKey: string | null = null; +export function createCollabCloudService( + deps: CollabCloudServiceDeps, +): CollabCloudService { + const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const now = deps.now ?? Date.now; + const retryDelayMs = + deps.retryDelayMs ?? + ((attemptCount: number) => + Math.min(30_000, 1_000 * 2 ** Math.min(Math.max(0, attemptCount), 5))); + // Per-project pull cursor + last ETag, so each poll only fetches new comments + // and a 304 costs nothing. + const cursors = new Map(); + const etags = new Map(); + const inFlightPulls = new Map>(); + let timer: NodeJS.Timeout | null = null; + let running = false; + let outboxRunning = false; + let outboxRerunRequested = false; + let started = false; + // The identity we last pushed to the member directory. Re-registering only + // when this changes keeps `pollOnce` from spawning a `vela member register` + // process on every 5s tick (see pollOnce). + let lastRegisteredKey: string | null = null; - function explicitTeamIdentity(context: WorkspaceCollabContext): { - teamId: string; - memberId: string; - role: 'owner' | 'admin' | 'member'; - displayName: string; - } | null { - if ( - context.workspaceType !== 'team' - || context.memberStatus !== 'active' - || context.lifecycleState === 'deleted' - ) { - return null; - } - const teamId = context.teamId?.trim() || context.workspaceId.trim(); - const memberId = context.workspaceMemberId.trim(); - if (!teamId || !memberId) return null; - return { - teamId, - memberId, - role: context.role, - displayName: context.displayName?.trim() || memberId - }; - } + function explicitTeamIdentity(context: WorkspaceCollabContext): { + teamId: string; + memberId: string; + role: "owner" | "admin" | "member"; + displayName: string; + } | null { + if ( + context.workspaceType !== "team" || + context.memberStatus !== "active" || + context.lifecycleState === "deleted" + ) { + return null; + } + const teamId = context.teamId?.trim() || context.workspaceId.trim(); + const memberId = context.workspaceMemberId.trim(); + if (!teamId || !memberId) return null; + return { + teamId, + memberId, + role: context.role, + displayName: context.displayName?.trim() || memberId, + }; + } - type PullIdentity = { - teamId: string; - memberId: string; - relayScope: 'team' | 'personal'; - allowedFilePaths?: ReadonlySet; - }; + type PullIdentity = { + teamId: string; + memberId: string; + relayScope: "team" | "personal"; + allowedFilePaths?: ReadonlySet; + }; - // Personal pulls share one remote project stream, but each publication set - // sees only a subset of it. Keep their validators and high-water marks apart: - // advancing a global cursor after filtering would permanently acknowledge a - // comment for a file published (or resumed) later. - function pullCursorKey(scopeKey: string, projectId: string, identity: PullIdentity): string { - if (identity.relayScope === 'team') return `${scopeKey}:${projectId}`; - const filePaths = [...(identity.allowedFilePaths ?? [])].sort(); - return `${scopeKey}:${projectId}:personal:${JSON.stringify(filePaths)}`; - } + // Personal pulls share one remote project stream, but each publication set + // sees only a subset of it. Keep their validators and high-water marks apart: + // advancing a global cursor after filtering would permanently acknowledge a + // comment for a file published (or resumed) later. + function pullCursorKey( + scopeKey: string, + projectId: string, + identity: PullIdentity, + ): string { + if (identity.relayScope === "team") return `${scopeKey}:${projectId}`; + const filePaths = [...(identity.allowedFilePaths ?? [])].sort(); + return `${scopeKey}:${projectId}:personal:${JSON.stringify(filePaths)}`; + } - function personalPullIdentity( - projectId: string, - context: WorkspaceCollabContext, - ): PullIdentity | null { - if ( - context.workspaceType !== 'personal' - || context.memberStatus !== 'active' - || context.lifecycleState === 'deleted' - ) return null; - const memberId = context.workspaceMemberId.trim(); - const teamId = context.workspaceId.trim(); - const allowedFilePaths = deps.listPersonalCommentRelayFilePaths?.(projectId, context); - if (!memberId || !teamId || !allowedFilePaths || allowedFilePaths.size === 0) return null; - return { teamId, memberId, relayScope: 'personal', allowedFilePaths }; - } + function personalPullIdentity( + projectId: string, + context: WorkspaceCollabContext, + ): PullIdentity | null { + if ( + context.workspaceType !== "personal" || + context.memberStatus !== "active" || + context.lifecycleState === "deleted" + ) + return null; + const memberId = context.workspaceMemberId.trim(); + const teamId = context.workspaceId.trim(); + const allowedFilePaths = deps.listPersonalCommentRelayFilePaths?.( + projectId, + context, + ); + if ( + !memberId || + !teamId || + !allowedFilePaths || + allowedFilePaths.size === 0 + ) + return null; + return { teamId, memberId, relayScope: "personal", allowedFilePaths }; + } - function pullIdentity(projectId: string, context: WorkspaceCollabContext): PullIdentity | null { - const team = explicitTeamIdentity(context); - if (team) return { teamId: team.teamId, memberId: team.memberId, relayScope: 'team' }; - return personalPullIdentity(projectId, context); - } + function pullIdentity( + projectId: string, + context: WorkspaceCollabContext, + ): PullIdentity | null { + const team = explicitTeamIdentity(context); + if (team) + return { + teamId: team.teamId, + memberId: team.memberId, + relayScope: "team", + }; + return personalPullIdentity(projectId, context); + } - function relayIdentity( - context: WorkspaceCollabContext, - projectId: string, - filePath: string, - ): { teamId: string; memberId: string; role: 'owner' | 'admin' | 'member'; displayName: string; relayScope: 'team' | 'personal' } | null { - const scoped = deps.commentRelayScope?.(projectId, filePath, context); - if (!scoped) { - if (deps.commentRelayScope) return null; - const team = explicitTeamIdentity(context); - return team ? { ...team, relayScope: 'team' } : null; - } - if (context.memberStatus !== 'active' || context.lifecycleState === 'deleted') return null; - const memberId = context.workspaceMemberId.trim(); - if (!memberId || context.workspaceId !== scoped.workspaceId) return null; - return { teamId: scoped.teamId, memberId, role: context.role, displayName: context.displayName?.trim() || memberId, relayScope: scoped.relayScope }; - } + function relayIdentity( + context: WorkspaceCollabContext, + projectId: string, + filePath: string, + ): { + teamId: string; + memberId: string; + role: "owner" | "admin" | "member"; + displayName: string; + relayScope: "team" | "personal"; + } | null { + const scoped = deps.commentRelayScope?.(projectId, filePath, context); + if (!scoped) { + if (deps.commentRelayScope) return null; + const team = explicitTeamIdentity(context); + return team ? { ...team, relayScope: "team" } : null; + } + if ( + context.memberStatus !== "active" || + context.lifecycleState === "deleted" + ) + return null; + const memberId = context.workspaceMemberId.trim(); + if (!memberId || context.workspaceId !== scoped.workspaceId) return null; + return { + teamId: scoped.teamId, + memberId, + role: context.role, + displayName: context.displayName?.trim() || memberId, + relayScope: scoped.relayScope, + }; + } - async function registerSelf( - context?: WorkspaceCollabContext, - ): Promise { - const identity = context ? explicitTeamIdentity(context) : null; - if (!identity) return; - await deps.client.registerMember(identity.teamId, identity.memberId, { - displayName: identity.displayName, - role: identity.role, - }); - } + async function registerSelf(context?: WorkspaceCollabContext): Promise { + const identity = context ? explicitTeamIdentity(context) : null; + if (!identity) return; + await deps.client.registerMember(identity.teamId, identity.memberId, { + displayName: identity.displayName, + role: identity.role, + }); + } - async function pushComment( - comment: PreviewComment, - context: WorkspaceCollabContext, - ): Promise<{ seq: number } | null> { - const identity = relayIdentity(context, comment.projectId, comment.filePath); - if (!identity) return null; - const cloud = previewCommentToCloud(comment, identity.memberId); - const result = await deps.client.pushComment(identity.teamId, comment.projectId, cloud); - return result ?? null; - } + async function pushComment( + comment: PreviewComment, + context: WorkspaceCollabContext, + ): Promise<{ seq: number } | null> { + const identity = relayIdentity( + context, + comment.projectId, + comment.filePath, + ); + if (!identity) return null; + const cloud = previewCommentToCloud(comment, identity.memberId); + const result = await deps.client.pushComment( + identity.teamId, + comment.projectId, + cloud, + ); + return result ?? null; + } - async function pushCommentDeletion( - comment: PreviewComment, - context: WorkspaceCollabContext, - ): Promise { - const identity = relayIdentity(context, comment.projectId, comment.filePath); - if (!identity) return; - const cloud = previewCommentToCloud(comment, identity.memberId); - cloud.deleted = true; - // The tombstone's own event time — newer than the comment's last content - // edit so it can't be mistaken for a stale record on the relay/receiver. - cloud.updatedAt = Date.now(); - await deps.client.pushComment(identity.teamId, comment.projectId, cloud); - } + async function pushCommentDeletion( + comment: PreviewComment, + context: WorkspaceCollabContext, + ): Promise { + const identity = relayIdentity( + context, + comment.projectId, + comment.filePath, + ); + if (!identity) return; + const cloud = previewCommentToCloud(comment, identity.memberId); + cloud.deleted = true; + // The tombstone's own event time — newer than the comment's last content + // edit so it can't be mistaken for a stale record on the relay/receiver. + cloud.updatedAt = Date.now(); + await deps.client.pushComment(identity.teamId, comment.projectId, cloud); + } - function queuedCloudComment( - comment: PreviewComment, - context: WorkspaceCollabContext, - deleted: boolean, - ): boolean { - if (!deps.commentOutbox) return false; - const identity = relayIdentity(context, comment.projectId, comment.filePath); - if (!identity) return false; - const localBinding = deps.resolveLocalProjectRelayBinding?.(comment.projectId) ?? null; - const expectedOwnerMemberId = localBinding?.ownerMemberId?.trim() || null; - if ( - !localBinding - || localBinding.workspaceId !== context.workspaceId - ) return false; - const cloud = previewCommentToCloud(comment, identity.memberId); - if (deleted) { - cloud.deleted = true; - cloud.updatedAt = now(); - } - try { - deps.commentOutbox.enqueue({ - workspaceId: context.workspaceId, - workspaceMemberId: identity.memberId, - teamId: identity.teamId, - relayScope: identity.relayScope, - projectId: comment.projectId, - expectedOwnerMemberId, - comment: cloud, - }); - } catch (error) { - deps.onError?.(error); - return false; - } - if (started) { - queueMicrotask(() => { - void flushPendingComments().catch((error) => deps.onError?.(error)); - }); - } - return true; - } + function queuedCloudComment( + comment: PreviewComment, + context: WorkspaceCollabContext, + deleted: boolean, + ): boolean { + if (!deps.commentOutbox) return false; + const identity = relayIdentity( + context, + comment.projectId, + comment.filePath, + ); + if (!identity) return false; + const localBinding = + deps.resolveLocalProjectRelayBinding?.(comment.projectId) ?? null; + const expectedOwnerMemberId = localBinding?.ownerMemberId?.trim() || null; + if (!localBinding || localBinding.workspaceId !== context.workspaceId) + return false; + const cloud = previewCommentToCloud(comment, identity.memberId); + if (deleted) { + cloud.deleted = true; + cloud.updatedAt = now(); + } + try { + deps.commentOutbox.enqueue({ + workspaceId: context.workspaceId, + workspaceMemberId: identity.memberId, + teamId: identity.teamId, + relayScope: identity.relayScope, + projectId: comment.projectId, + expectedOwnerMemberId, + comment: cloud, + }); + } catch (error) { + deps.onError?.(error); + return false; + } + if (started) { + queueMicrotask(() => { + void flushPendingComments().catch((error) => deps.onError?.(error)); + }); + } + return true; + } - /** A stopped public share is a server-confirmed terminal state for this exact durable revision. */ - function isShareStoppedRelayError(error: unknown): error is CollabCloudError { - return error instanceof CollabCloudError - && error.status === SHARE_COMMENT_TERMINAL_REJECTION.status - && error.code === SHARE_COMMENT_TERMINAL_REJECTION.code; - } + /** A stopped public share is a server-confirmed terminal state for this exact durable revision. */ + function isShareStoppedRelayError(error: unknown): error is CollabCloudError { + return ( + error instanceof CollabCloudError && + error.status === SHARE_COMMENT_TERMINAL_REJECTION.status && + error.code === SHARE_COMMENT_TERMINAL_REJECTION.code + ); + } - function deferOutboxRecord(record: CommentRelayOutboxRecord, error: unknown): void { - const attemptCount = record.attemptCount + 1; - const message = error instanceof Error ? error.message : String(error); - deps.commentOutbox?.defer(record, { - nextAttemptAt: now() + Math.max(0, retryDelayMs(attemptCount)), - error: message, - }); - deps.onError?.(error); - } + function deferOutboxRecord( + record: CommentRelayOutboxRecord, + error: unknown, + ): void { + const attemptCount = record.attemptCount + 1; + const message = error instanceof Error ? error.message : String(error); + deps.commentOutbox?.defer(record, { + nextAttemptAt: now() + Math.max(0, retryDelayMs(attemptCount)), + error: message, + }); + deps.onError?.(error); + } - const relayIdentityKey = (record: CommentRelayOutboxIdentity): string => - JSON.stringify([record.workspaceId, record.workspaceMemberId, record.teamId, record.relayScope]); + const relayIdentityKey = (record: CommentRelayOutboxIdentity): string => + JSON.stringify([ + record.workspaceId, + record.workspaceMemberId, + record.teamId, + record.relayScope, + ]); - const relayIdentityMatches = ( - context: WorkspaceCollabContext, - record: CommentRelayOutboxRecord, - ) => { - const identity = relayIdentity(context, record.projectId, record.comment.filePath); - return identity - && identity.relayScope === record.relayScope - && context.workspaceId === record.workspaceId - && identity.memberId === record.workspaceMemberId - && identity.teamId === record.teamId - ? identity - : null; - }; + const relayIdentityMatches = ( + context: WorkspaceCollabContext, + record: CommentRelayOutboxRecord, + ) => { + const identity = relayIdentity( + context, + record.projectId, + record.comment.filePath, + ); + return identity && + identity.relayScope === record.relayScope && + context.workspaceId === record.workspaceId && + identity.memberId === record.workspaceMemberId && + identity.teamId === record.teamId + ? identity + : null; + }; - const personalBatchIdentityMatches = ( - context: WorkspaceCollabContext, - record: CommentRelayOutboxRecord, - ): { teamId: string; memberId: string } | null => { - const memberId = context.workspaceMemberId.trim(); - return context.memberStatus === 'active' - && context.lifecycleState !== 'deleted' - && context.workspaceId === record.workspaceId - && memberId === record.workspaceMemberId - && record.teamId === record.workspaceId - ? { teamId: record.teamId, memberId } - : null; - }; + const personalBatchIdentityMatches = ( + context: WorkspaceCollabContext, + record: CommentRelayOutboxRecord, + ): { teamId: string; memberId: string } | null => { + const memberId = context.workspaceMemberId.trim(); + return context.memberStatus === "active" && + context.lifecycleState !== "deleted" && + context.workspaceId === record.workspaceId && + memberId === record.workspaceMemberId && + record.teamId === record.workspaceId + ? { teamId: record.teamId, memberId } + : null; + }; - async function pushOutboxRecord( - record: CommentRelayOutboxRecord, - identity: { teamId: string; memberId: string }, - ): Promise { - try { - // Recheck each publication-bound record immediately before network I/O: - // an earlier record may have awaited while stop/re-publish changed the witness. - if (record.publication && !deps.commentOutbox?.isPublicationCurrent?.(record)) { - deps.commentOutbox?.acknowledge(record); - return; - } - const result = await deps.client.pushComment( - identity.teamId, - record.projectId, - record.publication ? { ...record.comment, filePath: record.publication.publicFilePath } : record.comment, - ); - // Revision-conditional ACK: if an edit/delete was queued while this - // payload was in flight, its newer row remains for the next drain. - deps.commentOutbox?.acknowledge(record, 'delivered'); - if (!record.comment.deleted) { - deps.onCommentPushed?.({ - projectId: record.projectId, - commentId: record.commentId, - seq: result.seq, - memberId: record.workspaceMemberId, - ...(result.authorKey ? { authorKey: result.authorKey } : {}), - }); - } - } catch (error) { - if (isShareStoppedRelayError(error)) { - // The server has authoritatively closed this share. A revision-conditional - // ACK preserves a newer local edit that raced this rejected request. - deps.commentOutbox?.acknowledge(record); - deps.onError?.(error); - return; - } - deferOutboxRecord(record, error); - } - } + async function pushOutboxRecord( + record: CommentRelayOutboxRecord, + identity: { teamId: string; memberId: string }, + ): Promise { + try { + // Recheck each publication-bound record immediately before network I/O: + // an earlier record may have awaited while stop/re-publish changed the witness. + if ( + record.publication && + !deps.commentOutbox?.isPublicationCurrent?.(record) + ) { + deps.commentOutbox?.acknowledge(record); + return; + } + const result = await deps.client.pushComment( + identity.teamId, + record.projectId, + record.publication + ? { + ...record.comment, + filePath: record.publication.publicFilePath, + publicationSlug: record.publication.slug, + } + : record.comment, + record.eventKey, + // Only the durable owner-mediated publication relay may request an + // original member. Vela independently verifies this owner, active + // publication and same-team directory before honoring the hint. + record.publication && record.comment.memberId && record.comment.memberId !== identity.memberId + ? record.comment.memberId : undefined, + ); + // Revision-conditional ACK: if an edit/delete was queued while this + // payload was in flight, its newer row remains for the next drain. + deps.commentOutbox?.acknowledge(record, "delivered"); + if (!record.comment.deleted) { + deps.onCommentPushed?.({ + projectId: record.projectId, + commentId: record.commentId, + seq: result.seq, + memberId: record.workspaceMemberId, + ...(result.authorKey ? { authorKey: result.authorKey } : {}), + }); + } + } catch (error) { + if (isShareStoppedRelayError(error)) { + // The server has authoritatively closed this share. A revision-conditional + // ACK preserves a newer local edit that raced this rejected request. + deps.commentOutbox?.acknowledge(record); + deps.onError?.(error); + return; + } + deferOutboxRecord(record, error); + } + } - async function pushOutboxProjectLanes( - records: CommentRelayOutboxRecord[], - identity: { teamId: string; memberId: string }, - ): Promise { - const lanes = new Map(); - for (const record of records) { - const lane = lanes.get(record.projectId); - if (lane) lane.push(record); - else lanes.set(record.projectId, [record]); - } - const pendingLanes = [...lanes.values()]; - let nextLane = 0; - const worker = async () => { - while (nextLane < pendingLanes.length) { - const lane = pendingLanes[nextLane]; - nextLane += 1; - if (!lane) continue; - // A project's relay sequence is user-visible comment order. Keep that - // lane strictly serial while unrelated projects use spare capacity. - for (const record of lane) await pushOutboxRecord(record, identity); - } - }; - await Promise.all( - Array.from( - { length: Math.min(COMMENT_OUTBOX_PUSH_CONCURRENCY, pendingLanes.length) }, - () => worker(), - ), - ); - } + async function pushOutboxProjectLanes( + records: CommentRelayOutboxRecord[], + identity: { teamId: string; memberId: string }, + ): Promise { + const lanes = new Map(); + for (const record of records) { + const lane = lanes.get(record.projectId); + if (lane) lane.push(record); + else lanes.set(record.projectId, [record]); + } + const pendingLanes = [...lanes.values()]; + let nextLane = 0; + const worker = async () => { + while (nextLane < pendingLanes.length) { + const lane = pendingLanes[nextLane]; + nextLane += 1; + if (!lane) continue; + // A project's relay sequence is user-visible comment order. Keep that + // lane strictly serial while unrelated projects use spare capacity. + for (const record of lane) await pushOutboxRecord(record, identity); + } + }; + await Promise.all( + Array.from( + { + length: Math.min( + COMMENT_OUTBOX_PUSH_CONCURRENCY, + pendingLanes.length, + ), + }, + () => worker(), + ), + ); + } - async function flushLegacyOutboxRecord(record: CommentRelayOutboxRecord): Promise { - try { - const context = - await deps.resolveProjectWorkspaceContext?.( - record.projectId, - { fresh: true }, - ) ?? null; - const identity = context ? relayIdentityMatches(context, record) : null; - if (!context || !identity) { - deferOutboxRecord( - record, - new Error('comment relay delivery authority is unavailable or changed'), - ); - return; - } - const remoteOwnerMemberId = - await deps.resolveRemoteProjectOwnerMemberId?.( - record.projectId, - context, - ); - if ( - !remoteOwnerMemberId - || ( - record.expectedOwnerMemberId !== null - && remoteOwnerMemberId !== record.expectedOwnerMemberId - ) - ) { - deps.commentOutbox?.acknowledge(record); - if ( - remoteOwnerMemberId - && record.expectedOwnerMemberId !== null - && remoteOwnerMemberId !== record.expectedOwnerMemberId - ) { - deps.onError?.(new Error('comment relay delivery owner changed; canceled')); - } - return; - } - await pushOutboxRecord(record, identity); - } catch (error) { - deferOutboxRecord(record, error); - } - } + async function flushLegacyOutboxRecord( + record: CommentRelayOutboxRecord, + ): Promise { + try { + const context = + (await deps.resolveProjectWorkspaceContext?.(record.projectId, { + fresh: true, + })) ?? null; + const identity = context ? relayIdentityMatches(context, record) : null; + if (!context || !identity) { + deferOutboxRecord( + record, + new Error( + "comment relay delivery authority is unavailable or changed", + ), + ); + return; + } + const remoteOwnerMemberId = + await deps.resolveRemoteProjectOwnerMemberId?.( + record.projectId, + context, + ); + if ( + !remoteOwnerMemberId || + (record.expectedOwnerMemberId !== null && + remoteOwnerMemberId !== record.expectedOwnerMemberId) + ) { + deps.commentOutbox?.acknowledge(record); + if ( + remoteOwnerMemberId && + record.expectedOwnerMemberId !== null && + remoteOwnerMemberId !== record.expectedOwnerMemberId + ) { + deps.onError?.( + new Error("comment relay delivery owner changed; canceled"), + ); + } + return; + } + await pushOutboxRecord(record, identity); + } catch (error) { + deferOutboxRecord(record, error); + } + } - async function flushOutboxIdentityBatch( - records: CommentRelayOutboxRecord[], - ): Promise { - const representative = records[0]; - if (!representative) return; - let eligible = records; - if (deps.validateCommentRelayProjectBinding) { - eligible = []; - for (const record of records) { - if (deps.validateCommentRelayProjectBinding(record)) eligible.push(record); - else { - // A local unshare/delete/re-home is authoritative and cannot become - // valid again for this queued revision. Cancel it even if the remote - // catalog still carries a briefly-stale row. - deps.commentOutbox?.acknowledge(record); - deps.onError?.(new Error('comment relay project binding changed; canceled')); - } - } - } - if (eligible.length === 0) return; + async function flushOutboxIdentityBatch( + records: CommentRelayOutboxRecord[], + ): Promise { + const representative = records[0]; + if (!representative) return; + let eligible = records; + if (deps.validateCommentRelayProjectBinding) { + eligible = []; + for (const record of records) { + if (deps.validateCommentRelayProjectBinding(record)) + eligible.push(record); + else { + // A local unshare/delete/re-home is authoritative and cannot become + // valid again for this queued revision. Cancel it even if the remote + // catalog still carries a briefly-stale row. + deps.commentOutbox?.acknowledge(record); + deps.onError?.( + new Error("comment relay project binding changed; canceled"), + ); + } + } + } + if (eligible.length === 0) return; - let context: WorkspaceCollabContext | null; - try { - context = await deps.resolveCommentRelayWorkspaceContext?.( - representative, - { fresh: true }, - ) ?? null; - } catch (error) { - for (const record of eligible) deferOutboxRecord(record, error); - return; - } - const identity = context - ? representative.relayScope === 'personal' - ? personalBatchIdentityMatches(context, representative) - : relayIdentityMatches(context, representative) - : null; - if (!context || !identity) { - const error = new Error('comment relay delivery authority is unavailable or changed'); - for (const record of eligible) deferOutboxRecord(record, error); - return; - } + let context: WorkspaceCollabContext | null; + try { + context = + (await deps.resolveCommentRelayWorkspaceContext?.(representative, { + fresh: true, + })) ?? null; + } catch (error) { + for (const record of eligible) deferOutboxRecord(record, error); + return; + } + const identity = context + ? representative.relayScope === "personal" + ? personalBatchIdentityMatches(context, representative) + : relayIdentityMatches(context, representative) + : null; + if (!context || !identity) { + const error = new Error( + "comment relay delivery authority is unavailable or changed", + ); + for (const record of eligible) deferOutboxRecord(record, error); + return; + } - // A durable identity batch can span files and projects. For personal - // publications, each record must re-prove its creator-scoped, exact-file - // publication after fresh authority resolves and immediately before it is - // scheduled. Local binding only proves a project record still belongs here; - // it does not prove that this file remains published. - if (representative.relayScope === 'personal') { - const deliverable: CommentRelayOutboxRecord[] = []; - for (const record of eligible) { - if (relayIdentityMatches(context, record)) deliverable.push(record); - else { - // Fresh workspace authority is already proven for this identity - // batch. A failed exact-file scope check therefore means the local - // durable publication witness was removed (or its creator binding - // changed), not that login/workspace resolution is temporarily down. - // Do not let a stopped publication revive from SQLite after restart. - deps.commentOutbox?.acknowledge(record); - deps.onError?.(new Error('comment relay personal publication stopped or creator changed; canceled')); - } - } - await pushOutboxProjectLanes(deliverable, identity); - return; - } + // A durable identity batch can span files and projects. For personal + // publications, each record must re-prove its creator-scoped, exact-file + // publication after fresh authority resolves and immediately before it is + // scheduled. Local binding only proves a project record still belongs here; + // it does not prove that this file remains published. + if (representative.relayScope === "personal") { + const deliverable: CommentRelayOutboxRecord[] = []; + for (const record of eligible) { + if (relayIdentityMatches(context, record)) deliverable.push(record); + else { + // Fresh workspace authority is already proven for this identity + // batch. A failed exact-file scope check therefore means the local + // durable publication witness was removed (or its creator binding + // changed), not that login/workspace resolution is temporarily down. + // Do not let a stopped publication revive from SQLite after restart. + deps.commentOutbox?.acknowledge(record); + deps.onError?.( + new Error( + "comment relay personal publication stopped or creator changed; canceled", + ), + ); + } + } + await pushOutboxProjectLanes(deliverable, identity); + return; + } - let bindings: Array<{ projectId: string; ownerMemberId: string }>; - try { - bindings = await deps.listRemoteProjectRelayBindings?.(context) ?? []; - } catch (error) { - for (const record of eligible) deferOutboxRecord(record, error); - return; - } - const remoteOwners = new Map>(); - for (const binding of bindings) { - const projectId = binding.projectId.trim(); - const ownerMemberId = binding.ownerMemberId.trim(); - if (!projectId || !ownerMemberId) continue; - const owners = remoteOwners.get(projectId); - if (owners) owners.add(ownerMemberId); - else remoteOwners.set(projectId, new Set([ownerMemberId])); - } + let bindings: Array<{ projectId: string; ownerMemberId: string }>; + try { + bindings = (await deps.listRemoteProjectRelayBindings?.(context)) ?? []; + } catch (error) { + for (const record of eligible) deferOutboxRecord(record, error); + return; + } + const remoteOwners = new Map>(); + for (const binding of bindings) { + const projectId = binding.projectId.trim(); + const ownerMemberId = binding.ownerMemberId.trim(); + if (!projectId || !ownerMemberId) continue; + const owners = remoteOwners.get(projectId); + if (owners) owners.add(ownerMemberId); + else remoteOwners.set(projectId, new Set([ownerMemberId])); + } - const deliverable: CommentRelayOutboxRecord[] = []; - for (const record of eligible) { - const owners = remoteOwners.get(record.projectId); - const expectedOwner = record.expectedOwnerMemberId; - if (!owners || owners.size === 0) { - deps.commentOutbox?.acknowledge(record); - deps.onError?.(new Error('comment relay remote project owner missing; canceled')); - continue; - } - if (expectedOwner !== null && !owners.has(expectedOwner)) { - deps.commentOutbox?.acknowledge(record); - deps.onError?.(new Error('comment relay delivery owner changed; canceled')); - continue; - } - deliverable.push(record); - } - await pushOutboxProjectLanes(deliverable, identity); - } + const deliverable: CommentRelayOutboxRecord[] = []; + for (const record of eligible) { + const owners = remoteOwners.get(record.projectId); + const expectedOwner = record.expectedOwnerMemberId; + if (!owners || owners.size === 0) { + deps.commentOutbox?.acknowledge(record); + deps.onError?.( + new Error("comment relay remote project owner missing; canceled"), + ); + continue; + } + if (expectedOwner !== null && !owners.has(expectedOwner)) { + deps.commentOutbox?.acknowledge(record); + deps.onError?.( + new Error("comment relay delivery owner changed; canceled"), + ); + continue; + } + deliverable.push(record); + } + await pushOutboxProjectLanes(deliverable, identity); + } - async function flushPendingComments(): Promise { - if (!deps.commentOutbox) return; - if (outboxRunning) { - // A mutation can land after the active drain took its SQLite snapshot. - // Coalesce that signal into one follow-up pass instead of dropping it - // and making the revision wait for the next 5s poll tick. - outboxRerunRequested = true; - return; - } - outboxRunning = true; - try { - do { - outboxRerunRequested = false; - const pending = deps.commentOutbox.listDue(now()); - if (pending.length === 0) continue; - if ( - !deps.resolveCommentRelayWorkspaceContext - || !deps.listRemoteProjectRelayBindings - ) { - for (const record of pending) await flushLegacyOutboxRecord(record); - continue; - } - const batches = new Map(); - for (const record of pending) { - const key = relayIdentityKey(record); - const batch = batches.get(key); - if (batch) batch.push(record); - else batches.set(key, [record]); - } - // Identity batches remain serial so a login/session transition cannot - // overlap two principals. Pushes inside one verified batch are bounded. - for (const batch of batches.values()) await flushOutboxIdentityBatch(batch); - } while (outboxRerunRequested); - } finally { - outboxRunning = false; - } - } + async function flushPendingComments(): Promise { + if (!deps.commentOutbox) return; + if (outboxRunning) { + // A mutation can land after the active drain took its SQLite snapshot. + // Coalesce that signal into one follow-up pass instead of dropping it + // and making the revision wait for the next 5s poll tick. + outboxRerunRequested = true; + return; + } + outboxRunning = true; + try { + do { + outboxRerunRequested = false; + const pending = deps.commentOutbox.listDue(now()); + if (pending.length === 0) continue; + if ( + !deps.resolveCommentRelayWorkspaceContext || + !deps.listRemoteProjectRelayBindings + ) { + for (const record of pending) await flushLegacyOutboxRecord(record); + continue; + } + const batches = new Map(); + for (const record of pending) { + const key = relayIdentityKey(record); + const batch = batches.get(key); + if (batch) batch.push(record); + else batches.set(key, [record]); + } + // Identity batches remain serial so a login/session transition cannot + // overlap two principals. Pushes inside one verified batch are bounded. + for (const batch of batches.values()) + await flushOutboxIdentityBatch(batch); + } while (outboxRerunRequested); + } finally { + outboxRunning = false; + } + } - async function listMembersForTeamId( - teamId: string, - ): Promise { - if (!teamId) return []; - try { - return await deps.client.listMembers(teamId); - } catch (error) { - deps.onError?.(error); - // A transport failure is not evidence that the team has no members. - // Propagate it so the persistent + SWR layers retain their last-good - // roster and the invalidation poller retains its previous signature. - throw error; - } - } + async function listMembersForTeamId( + teamId: string, + ): Promise { + if (!teamId) return []; + try { + return await deps.client.listMembers(teamId); + } catch (error) { + deps.onError?.(error); + // A transport failure is not evidence that the team has no members. + // Propagate it so the persistent + SWR layers retain their last-good + // roster and the invalidation poller retains its previous signature. + throw error; + } + } - async function listMembers( - context: WorkspaceCollabContext, - ): Promise { - const identity = explicitTeamIdentity(context); - return identity ? listMembersForTeamId(identity.teamId) : []; - } + async function listMembers( + context: WorkspaceCollabContext, + ): Promise { + const identity = explicitTeamIdentity(context); + return identity ? listMembersForTeamId(identity.teamId) : []; + } - async function resolveMember( - memberId: string, - context: WorkspaceCollabContext, - ): Promise { - const identity = explicitTeamIdentity(context); - if (!identity) return null; - const members = await listMembersForTeamId(identity.teamId); - return members.find((m) => m.memberId === memberId) ?? null; - } + async function resolveMember( + memberId: string, + context: WorkspaceCollabContext, + ): Promise { + const identity = explicitTeamIdentity(context); + if (!identity) return null; + const members = await listMembersForTeamId(identity.teamId); + return members.find((m) => m.memberId === memberId) ?? null; + } - /** Resolves `true` when a pull ran (even if it returned nothing new), - * `false` when there was no local conversation to merge into. */ - async function pollProject( - identity: PullIdentity, - scopeKey: string, - projectId: string, - requestContext: WorkspaceCollabContext, - ): Promise { - const conversationId = deps.resolveLocalConversationId(projectId); - // No local conversation to attach to yet (e.g. a member who pulled the - // project but has not opened a chat) — nothing to merge into. - if (!conversationId) return false; - const requestCursorKey = pullCursorKey(scopeKey, projectId, identity); - const sinceSeq = cursors.get(requestCursorKey) ?? 0; - const result = await deps.client.pullComments( - identity.teamId, - projectId, - sinceSeq, - etags.get(requestCursorKey), - ); - // Personal relay eligibility is per published file. Re-check both the - // principal and active publication set after the async transport returns: - // an account/workspace switch or stop must never merge an in-flight reply. - const comments = result.comments; - let responseIdentity = identity; - if (identity.relayScope === 'personal') { - const freshContext = await deps.resolveProjectWorkspaceContext?.(projectId, { fresh: true }) ?? null; - const freshIdentity = freshContext ? personalPullIdentity(projectId, freshContext) : null; - if ( - !freshIdentity - || freshContext!.workspaceId !== requestContext.workspaceId - || freshIdentity.memberId !== identity.memberId - || freshIdentity.teamId !== identity.teamId - ) return false; - responseIdentity = freshIdentity; - // Apply per-record eligibility during sequential merging below: a later - // tombstone may target a row created earlier in this very response. - } - // Commit the result under the freshly-authoritative publication scope. - // If the set changed while a conditional request was in flight, a 304 is - // only valid for the old scope. Leave the new scope uncached so its next - // poll replays from zero instead of treating hidden comments as seen. - const responseCursorKey = pullCursorKey(scopeKey, projectId, responseIdentity); - if (result.notModified) { - if (responseCursorKey === requestCursorKey) etags.set(responseCursorKey, result.etag); - return true; - } - let inserted = 0; - for (const incoming of comments) { - let comment = incoming; - // Tombstones intentionally have no publication identity: their trusted - // project-scoped stored target remains the deletion authority below. - if (!incoming.deleted && 'publicationSlug' in incoming) { - const publicationSlug = incoming.publicationSlug; - if (typeof publicationSlug !== 'string' || !publicationSlug.trim()) { - throw new Error('Invalid server publication identity'); - } - if (!deps.resolvePublishedCommentSourcePath) throw new Error('Publication mapping lookup is unavailable'); - const filePath = deps.resolvePublishedCommentSourcePath({ - projectId, publicationSlug, publishedPath: incoming.filePath, - context: requestContext, - }); - if (filePath === null) continue; - comment = { ...incoming, filePath }; - } - if (responseIdentity.relayScope === 'personal') { - const allowed = responseIdentity.allowedFilePaths!; - if (comment.deleted) { - // A tombstone has no authoritative anchor. Even when it carries a - // path, only the stored, project-scoped target can authorize deletion. - // Resolve here, after preceding records have actually persisted. - if (!deps.resolveStoredCommentLocation) { - throw new Error('Stored comment location lookup is unavailable'); - } - const location = deps.resolveStoredCommentLocation(projectId, comment.id); - if (!location.found) continue; // Confirmed absence is a safe no-op. - if (!location.filePath) { - throw new Error('Stored comment location has no file path'); - } - if (!allowed.has(location.filePath)) continue; - } else if (!allowed.has(comment.filePath)) continue; - } - const outcome = deps.mergeComment({ projectId, conversationId, comment }); - if (outcome === 'changed') inserted += 1; - else if (outcome !== 'unchanged') { - throw new Error('Comment persistence did not acknowledge the pulled record'); - } - } - etags.set(responseCursorKey, result.etag); - cursors.set(responseCursorKey, result.latestSeq); - if (inserted > 0) deps.onMerged?.({ projectId, inserted }); - return true; - } + /** Resolves `true` when a pull ran (even if it returned nothing new), + * `false` when there was no local conversation to merge into. */ + async function pollProject( + identity: PullIdentity, + scopeKey: string, + projectId: string, + requestContext: WorkspaceCollabContext, + ): Promise { + const conversationId = deps.resolveLocalConversationId(projectId); + // No local conversation to attach to yet (e.g. a member who pulled the + // project but has not opened a chat) — nothing to merge into. + if (!conversationId) return false; + const requestCursorKey = pullCursorKey(scopeKey, projectId, identity); + const sinceSeq = cursors.get(requestCursorKey) ?? 0; + const result = await deps.client.pullComments( + identity.teamId, + projectId, + sinceSeq, + etags.get(requestCursorKey), + ); + // Personal relay eligibility is per published file. Re-check both the + // principal and active publication set after the async transport returns: + // an account/workspace switch or stop must never merge an in-flight reply. + const comments = result.comments; + let responseIdentity = identity; + if (identity.relayScope === "personal") { + const freshContext = + (await deps.resolveProjectWorkspaceContext?.(projectId, { + fresh: true, + })) ?? null; + const freshIdentity = freshContext + ? personalPullIdentity(projectId, freshContext) + : null; + if ( + !freshIdentity || + freshContext!.workspaceId !== requestContext.workspaceId || + freshIdentity.memberId !== identity.memberId || + freshIdentity.teamId !== identity.teamId + ) + return false; + responseIdentity = freshIdentity; + // Apply per-record eligibility during sequential merging below: a later + // tombstone may target a row created earlier in this very response. + } + // Commit the result under the freshly-authoritative publication scope. + // If the set changed while a conditional request was in flight, a 304 is + // only valid for the old scope. Leave the new scope uncached so its next + // poll replays from zero instead of treating hidden comments as seen. + const responseCursorKey = pullCursorKey( + scopeKey, + projectId, + responseIdentity, + ); + if (result.notModified) { + if (responseCursorKey === requestCursorKey) + etags.set(responseCursorKey, result.etag); + return true; + } + let inserted = 0; + for (const incoming of comments) { + let comment = incoming; + // Tombstones intentionally have no publication identity: their trusted + // project-scoped stored target remains the deletion authority below. + if (!incoming.deleted && "publicationSlug" in incoming) { + const publicationSlug = incoming.publicationSlug; + if (typeof publicationSlug !== "string" || !publicationSlug.trim()) { + throw new Error("Invalid server publication identity"); + } + if (!deps.resolvePublishedCommentSourcePath) + throw new Error("Publication mapping lookup is unavailable"); + const filePath = deps.resolvePublishedCommentSourcePath({ + projectId, + publicationSlug, + publishedPath: incoming.filePath, + context: requestContext, + }); + if (filePath === null) continue; + comment = { ...incoming, filePath }; + } + if (responseIdentity.relayScope === "personal") { + const allowed = responseIdentity.allowedFilePaths!; + if (comment.deleted) { + // A tombstone has no authoritative anchor. Even when it carries a + // path, only the stored, project-scoped target can authorize deletion. + // Resolve here, after preceding records have actually persisted. + if (!deps.resolveStoredCommentLocation) { + throw new Error("Stored comment location lookup is unavailable"); + } + const location = deps.resolveStoredCommentLocation( + projectId, + comment.id, + ); + if (!location.found) continue; // Confirmed absence is a safe no-op. + if (!location.filePath) { + throw new Error("Stored comment location has no file path"); + } + if (!allowed.has(location.filePath)) continue; + } else if (!allowed.has(comment.filePath)) continue; + } + const outcome = deps.mergeComment({ projectId, conversationId, comment }); + if (outcome === "changed") inserted += 1; + else if (outcome !== "unchanged") { + throw new Error( + "Comment persistence did not acknowledge the pulled record", + ); + } + } + etags.set(responseCursorKey, result.etag); + cursors.set(responseCursorKey, result.latestSeq); + if (inserted > 0) deps.onMerged?.({ projectId, inserted }); + return true; + } - function pullProjectSingleflight( - context: WorkspaceCollabContext, - identity: PullIdentity, - projectId: string, - ): Promise { - const scopeKey = `${context.workspaceId}:${identity.memberId}`; - const inFlightKey = JSON.stringify([ - context.workspaceId, - identity.teamId, - identity.memberId, - projectId, - ]); - const existing = inFlightPulls.get(inFlightKey); - if (existing) return existing; + function pullProjectSingleflight( + context: WorkspaceCollabContext, + identity: PullIdentity, + projectId: string, + ): Promise { + const scopeKey = `${context.workspaceId}:${identity.memberId}`; + const inFlightKey = JSON.stringify([ + context.workspaceId, + identity.teamId, + identity.memberId, + projectId, + ]); + const existing = inFlightPulls.get(inFlightKey); + if (existing) return existing; - const request = pollProject(identity, scopeKey, projectId, context) - .catch((error) => { - deps.onError?.(error); - return false; - }); - inFlightPulls.set(inFlightKey, request); - void request.then(() => { - if (inFlightPulls.get(inFlightKey) === request) { - inFlightPulls.delete(inFlightKey); - } - }); - return request; - } + const request = pollProject(identity, scopeKey, projectId, context).catch( + (error) => { + deps.onError?.(error); + return false; + }, + ); + inFlightPulls.set(inFlightKey, request); + void request.then(() => { + if (inFlightPulls.get(inFlightKey) === request) { + inFlightPulls.delete(inFlightKey); + } + }); + return request; + } - async function pullProject( - projectId: string, - context: WorkspaceCollabContext, - ): Promise { - const identity = pullIdentity(projectId, context); - if (!identity) return false; - return pullProjectSingleflight(context, identity, projectId); - } + async function pullProject( + projectId: string, + context: WorkspaceCollabContext, + ): Promise { + const identity = pullIdentity(projectId, context); + if (!identity) return false; + return pullProjectSingleflight(context, identity, projectId); + } - async function pollOnce(): Promise { - // Outbound delivery is durable and owns its own single-flight guard. Do - // not put inbound pulls behind a slow relay push: a stalled outbox row - // must not delay a teammate's newly-created comment from appearing. - void flushPendingComments().catch((error) => deps.onError?.(error)); - for (const projectId of deps.listProjectIds()) { - try { - const context = - await deps.resolveProjectWorkspaceContext?.(projectId) ?? null; - const identity = context ? pullIdentity(projectId, context) : null; - if (!context || !identity) continue; - // Team registration remains directory-only; personal publication pulls - // deliberately never fetch or synthesize a member directory identity. - if (identity.relayScope === 'team') { - const teamIdentity = explicitTeamIdentity(context)!; - // Refresh the exact project's member directory entry only when its - // immutable workspace/member identity changes. Never borrow the - // daemon's ambient active workspace. - const identityKey = - `${context.workspaceId}:${teamIdentity.teamId}:${teamIdentity.memberId}:` - + `${teamIdentity.role}:${teamIdentity.displayName}`; - if (identityKey !== lastRegisteredKey) { - await deps.client.registerMember(teamIdentity.teamId, teamIdentity.memberId, { - displayName: teamIdentity.displayName, - role: teamIdentity.role, - }); - lastRegisteredKey = identityKey; - } - } - await pullProjectSingleflight(context, identity, projectId); - } catch (error) { - deps.onError?.(error); - } - } - } + async function pollOnce(): Promise { + // Outbound delivery is durable and owns its own single-flight guard. Do + // not put inbound pulls behind a slow relay push: a stalled outbox row + // must not delay a teammate's newly-created comment from appearing. + void flushPendingComments().catch((error) => deps.onError?.(error)); + for (const projectId of deps.listProjectIds()) { + try { + const context = + (await deps.resolveProjectWorkspaceContext?.(projectId)) ?? null; + const identity = context ? pullIdentity(projectId, context) : null; + if (!context || !identity) continue; + // Team registration remains directory-only; personal publication pulls + // deliberately never fetch or synthesize a member directory identity. + if (identity.relayScope === "team") { + const teamIdentity = explicitTeamIdentity(context)!; + // Refresh the exact project's member directory entry only when its + // immutable workspace/member identity changes. Never borrow the + // daemon's ambient active workspace. + const identityKey = + `${context.workspaceId}:${teamIdentity.teamId}:${teamIdentity.memberId}:` + + `${teamIdentity.role}:${teamIdentity.displayName}`; + if (identityKey !== lastRegisteredKey) { + await deps.client.registerMember( + teamIdentity.teamId, + teamIdentity.memberId, + { + displayName: teamIdentity.displayName, + role: teamIdentity.role, + }, + ); + lastRegisteredKey = identityKey; + } + } + await pullProjectSingleflight(context, identity, projectId); + } catch (error) { + deps.onError?.(error); + } + } + } - function tick(): void { - if (running) return; - running = true; - void pollOnce() - .catch((error) => deps.onError?.(error)) - .finally(() => { - running = false; - }); - } + function tick(): void { + if (running) return; + running = true; + void pollOnce() + .catch((error) => deps.onError?.(error)) + .finally(() => { + running = false; + }); + } - return { - registerSelf, - pushComment, - pushCommentDeletion, - enqueueComment(comment, context) { - return queuedCloudComment(comment, context, false); - }, - enqueueCommentDeletion(comment, context) { - return queuedCloudComment(comment, context, true); - }, - flushPendingComments, - listMembers, - resolveMember, - pollOnce, - pullProject, - readMergedCommentCursor(projectId, context) { - const identity = pullIdentity(projectId, context); - if (!identity) return null; - return cursors.get(pullCursorKey(`${context.workspaceId}:${identity.memberId}`, projectId, identity)) ?? null; - }, - start() { - if (timer) return; - started = true; - timer = setInterval(tick, pollIntervalMs); - // Do not keep the event loop alive solely for polling. - timer.unref?.(); - // Recover rows left by a prior daemon before waiting a whole poll period. - tick(); - }, - dispose() { - started = false; - if (timer) { - clearInterval(timer); - timer = null; - } - }, - }; + return { + registerSelf, + pushComment, + pushCommentDeletion, + enqueueComment(comment, context) { + return queuedCloudComment(comment, context, false); + }, + enqueueCommentDeletion(comment, context) { + return queuedCloudComment(comment, context, true); + }, + flushPendingComments, + listMembers, + resolveMember, + pollOnce, + pullProject, + readMergedCommentCursor(projectId, context) { + const identity = pullIdentity(projectId, context); + if (!identity) return null; + return ( + cursors.get( + pullCursorKey( + `${context.workspaceId}:${identity.memberId}`, + projectId, + identity, + ), + ) ?? null + ); + }, + start() { + if (timer) return; + started = true; + timer = setInterval(tick, pollIntervalMs); + // Do not keep the event loop alive solely for polling. + timer.unref?.(); + // Recover rows left by a prior daemon before waiting a whole poll period. + tick(); + }, + dispose() { + started = false; + if (timer) { + clearInterval(timer); + timer = null; + } + }, + }; } diff --git a/apps/daemon/src/collab/comment-relay-outbox.ts b/apps/daemon/src/collab/comment-relay-outbox.ts index 1e867e0b17..8f9ca9fd7b 100644 --- a/apps/daemon/src/collab/comment-relay-outbox.ts +++ b/apps/daemon/src/collab/comment-relay-outbox.ts @@ -7,61 +7,76 @@ // is part of the row key: a later login or Workspace switch can never retarget // an old delivery to another Team or silently fall back to Personal. -import type Database from 'better-sqlite3'; -import type { CollabCloudComment } from '@open-design/contracts'; -import { currentCommentRelayPublicationMapping, migrateCommentRelayPublicationMappings } from './comment-relay-publication-mapping.js'; -import { markPublishedCommentBackfillOutcome } from './published-comment-backfill-state.js'; +import type Database from "better-sqlite3"; +import { createHash, randomUUID } from "node:crypto"; +import type { CollabCloudComment } from "@open-design/contracts"; +import { + acknowledgePublishedCommentMutation, + currentCommentRelayPublicationMapping, + migrateCommentRelayPublicationMappings, +} from "./comment-relay-publication-mapping.js"; +import { markPublishedCommentBackfillOutcome } from "./published-comment-backfill-state.js"; type SqliteDb = Database.Database; export interface CommentRelayOutboxIdentity { - workspaceId: string; - workspaceMemberId: string; - teamId: string; - /** Internal durable discriminator; existing rows default to team. */ - relayScope: 'team' | 'personal'; + workspaceId: string; + workspaceMemberId: string; + teamId: string; + /** Internal durable discriminator; existing rows default to team. */ + relayScope: "team" | "personal"; } export interface CommentRelayPublicationWitness { - slug: string; - token: string; - publicFilePath: string; + slug: string; + token: string; + publicFilePath: string; } export interface CommentRelayOutboxRecord extends CommentRelayOutboxIdentity { - projectId: string; - commentId: string; - expectedOwnerMemberId: string | null; - comment: CollabCloudComment; - publication?: CommentRelayPublicationWitness; - revision: number; - attemptCount: number; - nextAttemptAt: number; + projectId: string; + commentId: string; + expectedOwnerMemberId: string | null; + comment: CollabCloudComment; + publication?: CommentRelayPublicationWitness; + revision: number; + /** Stable for one logical mutation even after ACK, stop, restart, or same-alias resume. */ + eventKey: string; + attemptCount: number; + nextAttemptAt: number; } export interface CommentRelayOutboxStore { - enqueue(input: CommentRelayOutboxIdentity & { - projectId: string; - expectedOwnerMemberId: string | null; - comment: CollabCloudComment; - publication?: CommentRelayPublicationWitness; - }): void; - isPublicationCurrent?(record: CommentRelayOutboxRecord): boolean; - listDue(now: number, limit?: number): CommentRelayOutboxRecord[]; - /** Default acknowledgement is discard/cancel; only an explicit receipt is delivery. */ - acknowledge(record: CommentRelayOutboxRecord, outcome?: 'delivered' | 'discarded'): boolean; - defer(record: CommentRelayOutboxRecord, input: { - nextAttemptAt: number; - error: string; - }): boolean; - count(): number; + enqueue( + input: CommentRelayOutboxIdentity & { + projectId: string; + expectedOwnerMemberId: string | null; + comment: CollabCloudComment; + publication?: CommentRelayPublicationWitness; + }, + ): void; + isPublicationCurrent?(record: CommentRelayOutboxRecord): boolean; + listDue(now: number, limit?: number): CommentRelayOutboxRecord[]; + /** Default acknowledgement is discard/cancel; only an explicit receipt is delivery. */ + acknowledge( + record: CommentRelayOutboxRecord, + outcome?: "delivered" | "discarded", + ): boolean; + defer( + record: CommentRelayOutboxRecord, + input: { + nextAttemptAt: number; + error: string; + }, + ): boolean; + count(): number; } export interface PersonalCommentRelayPublicationScope { - resourceTeamId: string; - ownerMemberId: string; - projectId: string; - filePath: string; + resourceTeamId: string; + ownerMemberId: string; + projectId: string; + filePath: string; } /** @@ -71,47 +86,51 @@ export interface PersonalCommentRelayPublicationScope { * invalidates revisions already waiting in SQLite. */ export function cancelPersonalCommentRelayOutbox( - db: SqliteDb, - scope: PersonalCommentRelayPublicationScope, + db: SqliteDb, + scope: PersonalCommentRelayPublicationScope, ): void { - db.prepare(`DELETE FROM comment_relay_outbox + db.prepare(`DELETE FROM comment_relay_outbox WHERE workspace_id = ? AND workspace_member_id = ? AND team_id = ? AND relay_scope = 'personal' AND project_id = ? AND file_path = ?`).run( - scope.resourceTeamId, - scope.ownerMemberId, - scope.resourceTeamId, - scope.projectId, - scope.filePath, - ); + scope.resourceTeamId, + scope.ownerMemberId, + scope.resourceTeamId, + scope.projectId, + scope.filePath, + ); } export interface CommentRelayLocalProjectBinding { - workspaceId?: string | null; - visibility?: string | null; - resourceState?: string | null; - createdByWorkspaceMemberId?: string | null; + workspaceId?: string | null; + visibility?: string | null; + resourceState?: string | null; + createdByWorkspaceMemberId?: string | null; } export function commentRelayLocalBindingMatches( - record: CommentRelayOutboxRecord, - binding: CommentRelayLocalProjectBinding | null | undefined, + record: CommentRelayOutboxRecord, + binding: CommentRelayLocalProjectBinding | null | undefined, ): boolean { - if ( - binding?.workspaceId?.trim() !== record.workspaceId - || (record.relayScope === 'team' ? binding.visibility !== 'team' : binding.visibility !== 'personal') - || binding.resourceState === 'deleted' - ) return false; - const currentOwnerMemberId = binding.createdByWorkspaceMemberId?.trim() || null; - return currentOwnerMemberId === record.expectedOwnerMemberId; + if ( + binding?.workspaceId?.trim() !== record.workspaceId || + (record.relayScope === "team" + ? binding.visibility !== "team" + : binding.visibility !== "personal") || + binding.resourceState === "deleted" + ) + return false; + const currentOwnerMemberId = + binding.createdByWorkspaceMemberId?.trim() || null; + return currentOwnerMemberId === record.expectedOwnerMemberId; } export function migrateCommentRelayOutbox(db: SqliteDb): void { - migrateCommentRelayPublicationMappings(db); - db.exec(` + migrateCommentRelayPublicationMappings(db); + db.exec(` CREATE TABLE IF NOT EXISTS comment_relay_outbox ( workspace_id TEXT NOT NULL, workspace_member_id TEXT NOT NULL, @@ -122,6 +141,7 @@ export function migrateCommentRelayOutbox(db: SqliteDb): void { comment_id TEXT NOT NULL, expected_owner_member_id TEXT, payload_json TEXT NOT NULL, + event_key TEXT, revision INTEGER NOT NULL DEFAULT 1, attempt_count INTEGER NOT NULL DEFAULT 0, next_attempt_at INTEGER NOT NULL, @@ -131,6 +151,14 @@ export function migrateCommentRelayOutbox(db: SqliteDb): void { PRIMARY KEY (workspace_id, workspace_member_id, project_id, comment_id) ); + CREATE TABLE IF NOT EXISTS comment_relay_mutation_keys ( + workspace_id TEXT NOT NULL, workspace_member_id TEXT NOT NULL, + project_id TEXT NOT NULL, comment_id TEXT NOT NULL, + source_file_path TEXT NOT NULL, publication_slug TEXT NOT NULL, + payload_fingerprint TEXT NOT NULL, event_key TEXT NOT NULL, + PRIMARY KEY (workspace_id, workspace_member_id, project_id, comment_id) + ); + CREATE TABLE IF NOT EXISTS comment_relay_sync_failures ( workspace_id TEXT NOT NULL, workspace_member_id TEXT NOT NULL, @@ -142,80 +170,127 @@ export function migrateCommentRelayOutbox(db: SqliteDb): void { CREATE INDEX IF NOT EXISTS idx_comment_relay_outbox_due ON comment_relay_outbox(next_attempt_at, updated_at); `); - const columns = db.prepare('PRAGMA table_info(comment_relay_outbox)').all() as Array<{ name: string }>; - if (!columns.some(column => column.name === 'publication_json')) { - db.exec('ALTER TABLE comment_relay_outbox ADD COLUMN publication_json TEXT'); - } - // Compatible with rows written before personal publication relay existed. - try { db.exec("ALTER TABLE comment_relay_outbox ADD COLUMN relay_scope TEXT NOT NULL DEFAULT 'team'"); } catch { /* already migrated */ } - try { db.exec("ALTER TABLE comment_relay_outbox ADD COLUMN file_path TEXT NOT NULL DEFAULT ''"); } catch { /* already migrated */ } - // Personal rows predate the explicit exact-file cancellation key. Backfill - // from the durable payload once; malformed legacy JSON stays uncancelled and - // remains protected by the existing delivery-time eligibility gate. - db.exec(`UPDATE comment_relay_outbox + const columns = db + .prepare("PRAGMA table_info(comment_relay_outbox)") + .all() as Array<{ name: string }>; + if (!columns.some((column) => column.name === "publication_json")) { + db.exec( + "ALTER TABLE comment_relay_outbox ADD COLUMN publication_json TEXT", + ); + } + if (!columns.some((column) => column.name === "event_key")) + db.exec("ALTER TABLE comment_relay_outbox ADD COLUMN event_key TEXT"); + // Compatible with rows written before personal publication relay existed. + try { + db.exec( + "ALTER TABLE comment_relay_outbox ADD COLUMN relay_scope TEXT NOT NULL DEFAULT 'team'", + ); + } catch { + /* already migrated */ + } + try { + db.exec( + "ALTER TABLE comment_relay_outbox ADD COLUMN file_path TEXT NOT NULL DEFAULT ''", + ); + } catch { + /* already migrated */ + } + // Personal rows predate the explicit exact-file cancellation key. Backfill + // from the durable payload once; malformed legacy JSON stays uncancelled and + // remains protected by the existing delivery-time eligibility gate. + db.exec(`UPDATE comment_relay_outbox SET file_path = COALESCE(json_extract(payload_json, '$.filePath'), file_path) WHERE relay_scope = 'personal' AND file_path = '' AND json_valid(payload_json)`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_comment_relay_outbox_personal_publication + db.exec(`UPDATE comment_relay_outbox SET event_key = lower(hex(randomblob(16))) WHERE event_key IS NULL; + INSERT OR IGNORE INTO comment_relay_mutation_keys(workspace_id,workspace_member_id,project_id,comment_id,source_file_path,publication_slug,payload_fingerprint,event_key) + SELECT workspace_id,workspace_member_id,project_id,comment_id,file_path,COALESCE(json_extract(publication_json, '$.slug'), ''),'',event_key FROM comment_relay_outbox WHERE event_key IS NOT NULL`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_comment_relay_outbox_personal_publication ON comment_relay_outbox( workspace_id, workspace_member_id, team_id, relay_scope, project_id, file_path )`); } -function parsePublicationWitness(value: unknown): CommentRelayPublicationWitness { - if (!value || typeof value !== 'object' - || !('slug' in value) || typeof value.slug !== 'string' || !value.slug - || !('token' in value) || typeof value.token !== 'string' || !value.token - || !('publicFilePath' in value) || typeof value.publicFilePath !== 'string' || !value.publicFilePath) { - throw new Error('Invalid publication relay witness'); - } - return { slug: value.slug, token: value.token, publicFilePath: value.publicFilePath }; +function parsePublicationWitness( + value: unknown, +): CommentRelayPublicationWitness { + if ( + !value || + typeof value !== "object" || + !("slug" in value) || + typeof value.slug !== "string" || + !value.slug || + !("token" in value) || + typeof value.token !== "string" || + !value.token || + !("publicFilePath" in value) || + typeof value.publicFilePath !== "string" || + !value.publicFilePath + ) { + throw new Error("Invalid publication relay witness"); + } + return { + slug: value.slug, + token: value.token, + publicFilePath: value.publicFilePath, + }; } -function parseRecord(row: Record): CommentRelayOutboxRecord | null { - try { - const comment = JSON.parse(String(row.payloadJson ?? '')) as unknown; - if (!comment || typeof comment !== 'object' || Array.isArray(comment)) return null; - if ( - typeof row.workspaceId !== 'string' - || typeof row.workspaceMemberId !== 'string' - || typeof row.teamId !== 'string' - || (row.relayScope !== 'team' && row.relayScope !== 'personal') - || typeof row.projectId !== 'string' - || typeof row.commentId !== 'string' - || (row.expectedOwnerMemberId !== null && typeof row.expectedOwnerMemberId !== 'string') - ) { - return null; - } - return { - workspaceId: row.workspaceId, - workspaceMemberId: row.workspaceMemberId, - teamId: row.teamId, - relayScope: row.relayScope, - projectId: row.projectId, - commentId: row.commentId, - expectedOwnerMemberId: row.expectedOwnerMemberId as string | null, - comment: comment as CollabCloudComment, - ...(row.publicationJson == null ? {} : { publication: parsePublicationWitness(JSON.parse(String(row.publicationJson))) }), - revision: Number(row.revision), - attemptCount: Number(row.attemptCount), - nextAttemptAt: Number(row.nextAttemptAt), - }; - } catch { - return null; - } +function parseRecord( + row: Record, +): CommentRelayOutboxRecord | null { + try { + const comment = JSON.parse(String(row.payloadJson ?? "")) as unknown; + if (!comment || typeof comment !== "object" || Array.isArray(comment)) + return null; + if ( + typeof row.workspaceId !== "string" || + typeof row.workspaceMemberId !== "string" || + typeof row.teamId !== "string" || + (row.relayScope !== "team" && row.relayScope !== "personal") || + typeof row.projectId !== "string" || + typeof row.commentId !== "string" || + (row.expectedOwnerMemberId !== null && + typeof row.expectedOwnerMemberId !== "string") + ) { + return null; + } + return { + workspaceId: row.workspaceId, + workspaceMemberId: row.workspaceMemberId, + teamId: row.teamId, + relayScope: row.relayScope, + projectId: row.projectId, + commentId: row.commentId, + expectedOwnerMemberId: row.expectedOwnerMemberId as string | null, + comment: comment as CollabCloudComment, + ...(row.publicationJson == null + ? {} + : { + publication: parsePublicationWitness( + JSON.parse(String(row.publicationJson)), + ), + }), + revision: Number(row.revision), + eventKey: String(row.eventKey), + attemptCount: Number(row.attemptCount), + nextAttemptAt: Number(row.nextAttemptAt), + }; + } catch { + return null; + } } export function createCommentRelayOutboxStore( - db: SqliteDb, - now: () => number = Date.now, + db: SqliteDb, + now: () => number = Date.now, ): CommentRelayOutboxStore { - const enqueueRow = db.prepare(` + const enqueueRow = db.prepare(` INSERT INTO comment_relay_outbox (workspace_id, workspace_member_id, team_id, relay_scope, project_id, file_path, comment_id, expected_owner_member_id, - payload_json, publication_json, revision, attempt_count, next_attempt_at, last_error, + payload_json, publication_json, event_key, revision, attempt_count, next_attempt_at, last_error, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, NULL, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, NULL, ?, ?) ON CONFLICT(workspace_id, workspace_member_id, project_id, comment_id) DO UPDATE SET team_id = excluded.team_id, @@ -224,13 +299,14 @@ export function createCommentRelayOutboxStore( file_path = excluded.file_path, payload_json = excluded.payload_json, publication_json = excluded.publication_json, + event_key = excluded.event_key, revision = comment_relay_outbox.revision + 1, attempt_count = 0, next_attempt_at = excluded.next_attempt_at, last_error = NULL, updated_at = excluded.updated_at `); - const listDueRows = db.prepare(` + const listDueRows = db.prepare(` SELECT workspace_id AS workspaceId, workspace_member_id AS workspaceMemberId, team_id AS teamId, @@ -240,6 +316,7 @@ export function createCommentRelayOutboxStore( expected_owner_member_id AS expectedOwnerMemberId, payload_json AS payloadJson, publication_json AS publicationJson, + event_key AS eventKey, revision, attempt_count AS attemptCount, next_attempt_at AS nextAttemptAt @@ -248,7 +325,7 @@ export function createCommentRelayOutboxStore( ORDER BY next_attempt_at ASC, updated_at ASC LIMIT ? `); - const acknowledgeRow = db.prepare(` + const acknowledgeRow = db.prepare(` DELETE FROM comment_relay_outbox WHERE workspace_id = ? AND workspace_member_id = ? @@ -256,7 +333,7 @@ export function createCommentRelayOutboxStore( AND comment_id = ? AND revision = ? `); - const deferRow = db.prepare(` + const deferRow = db.prepare(` UPDATE comment_relay_outbox SET attempt_count = attempt_count + 1, next_attempt_at = ?, @@ -268,81 +345,170 @@ export function createCommentRelayOutboxStore( AND comment_id = ? AND revision = ? `); - const countRows = db.prepare(`SELECT COUNT(*) AS count FROM comment_relay_outbox`); + const countRows = db.prepare( + `SELECT COUNT(*) AS count FROM comment_relay_outbox`, + ); - return { - enqueue(input) { - const timestamp = now(); - const publication = input.publication ?? currentCommentRelayPublicationMapping(db, { - resourceTeamId: input.teamId, ownerMemberId: input.workspaceMemberId, - projectId: input.projectId, filePath: input.comment.filePath, - }); - enqueueRow.run( - input.workspaceId, - input.workspaceMemberId, - input.teamId, - input.relayScope, - input.projectId, - input.comment.filePath, - input.comment.id, - input.expectedOwnerMemberId, - JSON.stringify(input.comment), - publication ? JSON.stringify(publication) : null, - timestamp, - timestamp, - timestamp, - ); - }, - isPublicationCurrent(record) { - if (!record.publication) return true; - return Boolean(db.prepare(`SELECT 1 FROM public_file_publications + return { + enqueue(input) { + db.transaction(() => { + const timestamp = now(); + const publication = + input.publication ?? + currentCommentRelayPublicationMapping(db, { + resourceTeamId: input.teamId, + ownerMemberId: input.workspaceMemberId, + projectId: input.projectId, + filePath: input.comment.filePath, + }); + const fingerprint = createHash("sha256") + .update( + JSON.stringify([ + input.comment, + publication?.slug ?? "", + publication?.publicFilePath ?? "", + ]), + ) + .digest("hex"); + const saved = db + .prepare(`SELECT payload_fingerprint AS fingerprint, event_key AS eventKey FROM comment_relay_mutation_keys + WHERE workspace_id=? AND workspace_member_id=? AND project_id=? AND comment_id=?`) + .get( + input.workspaceId, + input.workspaceMemberId, + input.projectId, + input.comment.id, + ) as { fingerprint: string; eventKey: string } | undefined; + const eventKey = + saved?.fingerprint === fingerprint ? saved.eventKey : randomUUID(); + db.prepare(`INSERT INTO comment_relay_mutation_keys + (workspace_id,workspace_member_id,project_id,comment_id,source_file_path,publication_slug,payload_fingerprint,event_key) + VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(workspace_id,workspace_member_id,project_id,comment_id) + DO UPDATE SET source_file_path=excluded.source_file_path,publication_slug=excluded.publication_slug, + payload_fingerprint=excluded.payload_fingerprint,event_key=excluded.event_key`).run( + input.workspaceId, + input.workspaceMemberId, + input.projectId, + input.comment.id, + input.comment.filePath, + input.publication?.slug ?? "", + fingerprint, + eventKey, + ); + enqueueRow.run( + input.workspaceId, + input.workspaceMemberId, + input.teamId, + input.relayScope, + input.projectId, + input.comment.filePath, + input.comment.id, + input.expectedOwnerMemberId, + JSON.stringify(input.comment), + publication ? JSON.stringify(publication) : null, + eventKey, + timestamp, + timestamp, + timestamp, + ); + })(); + }, + isPublicationCurrent(record) { + if (!record.publication) return true; + return Boolean( + db + .prepare(`SELECT 1 FROM public_file_publications WHERE resource_team_id = ? AND owner_member_id = ? AND project_id = ? - AND file_path = ? AND slug = ? AND revision = ?`).get( - record.teamId, record.workspaceMemberId, record.projectId, record.comment.filePath, - record.publication.slug, record.publication.token, - )); - }, - listDue(timestamp, limit = 64) { - return (listDueRows.all(timestamp, Math.max(1, Math.round(limit))) as Record[]) - .map(parseRecord) - .filter((record): record is CommentRelayOutboxRecord => record !== null); - }, - acknowledge(record, outcome = 'discarded') { - return db.transaction(() => { - const changed = acknowledgeRow.run( - record.workspaceId, - record.workspaceMemberId, - record.projectId, - record.commentId, - record.revision, - ).changes > 0; - // Revision-conditional deletion is the race fence: an older in-flight - // acknowledgement cannot affect an overwritten queue row or its batch. - if (changed) markPublishedCommentBackfillOutcome(db, record, outcome); - return changed; - })(); - }, - defer(record, input) { - return db.transaction(() => { - const changed = deferRow.run( - input.nextAttemptAt, - input.error, - now(), - record.workspaceId, - record.workspaceMemberId, - record.projectId, - record.commentId, - record.revision, - ).changes > 0; - if (changed) markPublishedCommentBackfillOutcome(db, record, 'deferred'); - if (changed) db.prepare(`INSERT INTO comment_relay_sync_failures(workspace_id, workspace_member_id, project_id, failed_at) + AND file_path = ? AND slug = ? AND revision = ?`) + .get( + record.teamId, + record.workspaceMemberId, + record.projectId, + record.comment.filePath, + record.publication.slug, + record.publication.token, + ), + ); + }, + listDue(timestamp, limit = 64) { + return ( + listDueRows.all(timestamp, Math.max(1, Math.round(limit))) as Record< + string, + unknown + >[] + ) + .map(parseRecord) + .filter( + (record): record is CommentRelayOutboxRecord => record !== null, + ); + }, + acknowledge(record, outcome = "discarded") { + return db.transaction(() => { + const changed = + acknowledgeRow.run( + record.workspaceId, + record.workspaceMemberId, + record.projectId, + record.commentId, + record.revision, + ).changes > 0; + // Revision-conditional deletion is the race fence: an older in-flight + // acknowledgement cannot affect an overwritten queue row or its batch. + if (changed) { + markPublishedCommentBackfillOutcome(db, record, outcome); + if ( + outcome === "delivered" && + record.relayScope === "personal" && + record.publication + ) { + acknowledgePublishedCommentMutation(db, { + resourceTeamId: record.teamId, + ownerMemberId: record.workspaceMemberId, + projectId: record.projectId, + filePath: record.comment.filePath, + commentId: record.commentId, + slug: record.publication.slug, + revision: record.publication.token, + status: record.comment.status, + deleted: record.comment.deleted, + }); + } + } + return changed; + })(); + }, + defer(record, input) { + return db.transaction(() => { + const changed = + deferRow.run( + input.nextAttemptAt, + input.error, + now(), + record.workspaceId, + record.workspaceMemberId, + record.projectId, + record.commentId, + record.revision, + ).changes > 0; + if (changed) + markPublishedCommentBackfillOutcome(db, record, "deferred"); + if (changed) + db.prepare(`INSERT INTO comment_relay_sync_failures(workspace_id, workspace_member_id, project_id, failed_at) VALUES (?, ?, ?, ?) ON CONFLICT(workspace_id, workspace_member_id, project_id) - DO UPDATE SET failed_at=excluded.failed_at`).run(record.workspaceId, record.workspaceMemberId, record.projectId, now()); - return changed; - })(); - }, - count() { - return Number((countRows.get() as { count?: unknown } | undefined)?.count ?? 0); - }, - }; + DO UPDATE SET failed_at=excluded.failed_at`).run( + record.workspaceId, + record.workspaceMemberId, + record.projectId, + now(), + ); + return changed; + })(); + }, + count() { + return Number( + (countRows.get() as { count?: unknown } | undefined)?.count ?? 0, + ); + }, + }; } diff --git a/apps/daemon/src/collab/comment-relay-publication-mapping.ts b/apps/daemon/src/collab/comment-relay-publication-mapping.ts index 00814f1862..dfeebd94a2 100644 --- a/apps/daemon/src/collab/comment-relay-publication-mapping.ts +++ b/apps/daemon/src/collab/comment-relay-publication-mapping.ts @@ -1,4 +1,5 @@ import type Database from 'better-sqlite3'; +import type { CollabCloudComment } from '@open-design/contracts'; import type { CommentRelayPublicationWitness } from './comment-relay-outbox.js'; import type { PublicFilePublicationScope } from './public-file-publication-store.js'; @@ -10,6 +11,56 @@ export function migrateCommentRelayPublicationMappings(db: Database.Database): v slug TEXT NOT NULL, revision TEXT NOT NULL, public_file_path TEXT NOT NULL, PRIMARY KEY(resource_team_id, owner_member_id, project_id, file_path) )`); + db.exec('CREATE TABLE IF NOT EXISTS published_comment_mutations (resource_team_id TEXT NOT NULL, owner_member_id TEXT NOT NULL, project_id TEXT NOT NULL, file_path TEXT NOT NULL, comment_id TEXT NOT NULL, slug TEXT NOT NULL, publication_revision TEXT NOT NULL, payload_json TEXT NOT NULL, PRIMARY KEY(resource_team_id,owner_member_id,project_id,file_path,comment_id))'); +} + +/** Persist a publication-authorized mutation while its public alias is stopped. */ +export function recordPublishedCommentMutation(db: Database.Database, scope: PublicFilePublicationScope, comment: CollabCloudComment): boolean { + if (!db.inTransaction) throw new Error('Published comment mutation requires a transaction'); + const mapping = db.prepare('SELECT slug, revision FROM comment_relay_publication_mappings WHERE resource_team_id=? AND owner_member_id=? AND project_id=? AND file_path=?').get(scope.resourceTeamId, scope.ownerMemberId, scope.projectId, scope.filePath) as { slug: string; revision: string } | undefined; + if (!mapping) return false; + db.prepare('INSERT INTO published_comment_mutations (resource_team_id,owner_member_id,project_id,file_path,comment_id,slug,publication_revision,payload_json) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(resource_team_id,owner_member_id,project_id,file_path,comment_id) DO UPDATE SET slug=excluded.slug, publication_revision=excluded.publication_revision, payload_json=excluded.payload_json').run(scope.resourceTeamId, scope.ownerMemberId, scope.projectId, scope.filePath, comment.id, mapping.slug, mapping.revision, JSON.stringify(comment)); + return true; +} + +export function listPublishedCommentMutations(db: Database.Database, scope: PublicFilePublicationScope, slug: string): CollabCloudComment[] { + const mapping = db.prepare('SELECT revision FROM comment_relay_publication_mappings WHERE resource_team_id=? AND owner_member_id=? AND project_id=? AND file_path=? AND slug=?').get(scope.resourceTeamId, scope.ownerMemberId, scope.projectId, scope.filePath, slug) as { revision: string } | undefined; + if (!mapping) return []; + const rows = db.prepare('SELECT payload_json AS payloadJson FROM published_comment_mutations WHERE resource_team_id=? AND owner_member_id=? AND project_id=? AND file_path=? AND slug=? AND publication_revision=?').all(scope.resourceTeamId, scope.ownerMemberId, scope.projectId, scope.filePath, slug, mapping.revision) as Array<{payloadJson:string}>; + return rows.map(row => JSON.parse(row.payloadJson) as CollabCloudComment); +} + +/** Clear a journaled stop-race event only after the matching personal alias event is confirmed remotely. */ +export function acknowledgePublishedCommentMutation( + db: Database.Database, + input: { resourceTeamId: string; ownerMemberId: string; projectId: string; filePath: string; commentId: string; slug: string; revision: string; status: string; deleted?: boolean | undefined }, +): void { + if (!db.inTransaction) throw new Error('Published comment mutation acknowledgement requires a transaction'); + const row = db.prepare( + 'SELECT payload_json AS payloadJson FROM published_comment_mutations WHERE resource_team_id=? AND owner_member_id=? AND project_id=? AND file_path=? AND comment_id=? AND slug=? AND publication_revision=?', + ).get(input.resourceTeamId, input.ownerMemberId, input.projectId, input.filePath, input.commentId, input.slug, input.revision) as { payloadJson: string } | undefined; + if (!row) return; + const journaled = JSON.parse(row.payloadJson) as CollabCloudComment; + if (journaled.status !== input.status || Boolean(journaled.deleted) !== Boolean(input.deleted)) return; + db.prepare('DELETE FROM published_comment_mutations WHERE resource_team_id=? AND owner_member_id=? AND project_id=? AND file_path=? AND comment_id=? AND slug=? AND publication_revision=?') + .run(input.resourceTeamId, input.ownerMemberId, input.projectId, input.filePath, input.commentId, input.slug, input.revision); +} + +/** A resume changes the local witness, not the public alias. Keep unsent + * stopped mutations under the new witness until the relay confirms delivery; + * otherwise a second stop cancels their outbox and loses deleted identities. + */ +export function carryPublishedCommentMutationsToRevision( + db: Database.Database, + scope: PublicFilePublicationScope, + revision: { slug: string; token: string }, +): void { + if (!db.inTransaction) throw new Error('Published comment mutation carry requires a transaction'); + const mapping = db.prepare('SELECT revision FROM comment_relay_publication_mappings WHERE resource_team_id=? AND owner_member_id=? AND project_id=? AND file_path=? AND slug=?') + .get(scope.resourceTeamId, scope.ownerMemberId, scope.projectId, scope.filePath, revision.slug) as { revision: string } | undefined; + if (!mapping) return; + db.prepare('UPDATE published_comment_mutations SET publication_revision=? WHERE resource_team_id=? AND owner_member_id=? AND project_id=? AND file_path=? AND slug=? AND publication_revision=?') + .run(revision.token, scope.resourceTeamId, scope.ownerMemberId, scope.projectId, scope.filePath, revision.slug, mapping.revision); } export function recordCommentRelayPublicationMapping( diff --git a/apps/daemon/src/collab/comment-sync-state.ts b/apps/daemon/src/collab/comment-sync-state.ts index 3144e15564..78b89d8238 100644 --- a/apps/daemon/src/collab/comment-sync-state.ts +++ b/apps/daemon/src/collab/comment-sync-state.ts @@ -2,6 +2,7 @@ import type Database from 'better-sqlite3'; import type { CommentSyncState, CommentAlignResult } from '@open-design/contracts'; import { migrateCommentRelayOutbox } from './comment-relay-outbox.js'; import { readPublishedCommentBackfill } from './published-comment-backfill-state.js'; +import type { ReadProjectShareState } from './vela-project-share-state.js'; export interface CommentSyncScope { projectId: string; workspaceId: string; workspaceMemberId: string; filePath?: string } export interface CommentSyncStateService { @@ -14,7 +15,12 @@ export interface CommentSyncStateService { */ export function createCommentSyncStateService(db: Database.Database, sessionAvailable: (scope: CommentSyncScope) => Promise, - diagnostics?: { readAlign: (scope: CommentSyncScope) => CommentAlignResult | undefined }, + diagnostics?: { + readAlign?: (scope: CommentSyncScope) => CommentAlignResult | undefined; + readProjectShareState?: ReadProjectShareState; + /** Credential used for this GET; compare across availability and remote read. */ + readCredential?: () => { apiUrl: string; controlKey: string } | null; + }, ): CommentSyncStateService { migrateCommentRelayOutbox(db); const args = (scope: CommentSyncScope) => { @@ -24,17 +30,39 @@ export function createCommentSyncStateService(db: Database.Database, }; const read = async (scope: CommentSyncScope): Promise => { const identity = args(scope); + const credential = diagnostics?.readCredential?.(); + const sameCredential = () => { + if (!diagnostics?.readCredential) return true; + const current = diagnostics.readCredential(); + return Boolean(credential?.controlKey && credential.apiUrl + && current?.controlKey === credential.controlKey && current.apiUrl === credential.apiUrl); + }; const available = await sessionAvailable(scope); if (available === null) return null; // Query after the session await, not a stale count from before a send finished. const rows = db.prepare(`SELECT last_error FROM comment_relay_outbox WHERE project_id=? AND workspace_id=? AND workspace_member_id=? ORDER BY updated_at DESC`).all(...identity) as Array<{ last_error: string | null }>; - const align = diagnostics?.readAlign(scope); + const align = diagnostics?.readAlign?.(scope); const backfill = readPublishedCommentBackfill(db, scope); + // Stop removes the local publication row. Only the validated remote project + // history can establish whether this exact file is stopped or still active. + // A missing file, missing session or failed read is unknown, never 'active'. + let shareStopped: boolean | null = null; + if (available && scope.filePath && diagnostics?.readProjectShareState && sameCredential()) { + try { + const history = await diagnostics.readProjectShareState({ + projectId: scope.projectId, resourceTeamId: scope.workspaceId, ownerMemberId: scope.workspaceMemberId, + }); + if (history.projectId === scope.projectId && history.bindingExists) { + const file = history.publications.find(item => item.sourceFilePath === scope.filePath); + if (file && sameCredential()) shareStopped = file.status === 'stopped'; + } + } catch { /* Remote errors can contain credentials. Preserve unknown without logging. */ } + } return { ...(align ? { align } : {}), ...(backfill ? { backfill } : {}), pending: rows.length, // Arbitrary transport messages can contain credentials or other private data. lastError: (rows.some(row => Boolean(row.last_error)) || db.prepare('SELECT 1 FROM comment_relay_sync_failures WHERE project_id=? AND workspace_id=? AND workspace_member_id=?').get(...identity)) ? 'COMMENT_SYNC_DELIVERY_FAILED' : null, - sessionMissing: rows.length > 0 && !available, shareStopped: null }; + sessionMissing: rows.length > 0 && !available, shareStopped }; }; return { read, retry: async scope => { const identity = args(scope); diff --git a/apps/daemon/src/collab/public-file-publication-recording.ts b/apps/daemon/src/collab/public-file-publication-recording.ts index 1ec606e380..667a820077 100644 --- a/apps/daemon/src/collab/public-file-publication-recording.ts +++ b/apps/daemon/src/collab/public-file-publication-recording.ts @@ -2,11 +2,12 @@ import type Database from 'better-sqlite3'; import type { PublicFilePublicationScope, PublicFilePublication, PublicFilePublicationRevision, PublicFilePublicationStore } from './public-file-publication-store.js'; import { publishedPathForSource, type ShareFileMapping } from './share-file-mapping.js'; -export type RecordPublicFilePublication = (scope: PublicFilePublicationScope, publication: PublicFilePublication, mapping: ShareFileMapping) => PublicFilePublicationRevision; +export type RecordPublicFilePublication = (scope: PublicFilePublicationScope, publication: PublicFilePublication, mapping: ShareFileMapping, reopened?: boolean) => PublicFilePublicationRevision; type EnqueuePublishedFileComments = (db: Database.Database, input: { scope: PublicFilePublicationScope; publicationRevision: PublicFilePublicationRevision; publicFilePath: string; + reopened: boolean; }) => { enqueued: number; skippedInbound: number }; /** Record publication and its comment intent atomically, after network success. @@ -17,13 +18,13 @@ export function createPublicFilePublicationRecorder( publications: PublicFilePublicationStore, enqueue: EnqueuePublishedFileComments, ): RecordPublicFilePublication { - return db.transaction((scope: PublicFilePublicationScope, publication: PublicFilePublication, mapping: ShareFileMapping) => { + return db.transaction((scope: PublicFilePublicationScope, publication: PublicFilePublication, mapping: ShareFileMapping, reopened = false) => { const publicFilePath = publishedPathForSource(mapping, scope.filePath); if (!publicFilePath) throw new Error('SHARE_ENTRY_MAPPING_UNAVAILABLE'); publications.set(scope, publication); const publicationRevision = publications.getRevision(scope); if (!publicationRevision?.token || publicationRevision.slug !== publication.slug) throw new Error('PUBLICATION_WITNESS_UNAVAILABLE'); - enqueue(db, { scope, publicationRevision, publicFilePath }); + enqueue(db, { scope, publicationRevision, publicFilePath, reopened }); return publicationRevision; }); } diff --git a/apps/daemon/src/collab/published-comment-backfill-state.ts b/apps/daemon/src/collab/published-comment-backfill-state.ts index 353166b50f..e650a7845d 100644 --- a/apps/daemon/src/collab/published-comment-backfill-state.ts +++ b/apps/daemon/src/collab/published-comment-backfill-state.ts @@ -18,6 +18,8 @@ export interface RecordPublishedCommentBackfillInput { publicationRevision: PublicFilePublicationRevision; /** Exact ids present at publication time; later comments cannot satisfy this batch. */ commentIds: readonly string[]; + /** Trusted receipt origin; omitted legacy batches have unknown provenance. */ + reopened?: boolean; } export interface PublishedCommentBackfillOutboxRecord { @@ -41,6 +43,7 @@ export function migratePublishedCommentBackfillState(db: SqliteDb): void { state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')), retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)), code TEXT, + reopened INTEGER CHECK (reopened IN (0, 1)), PRIMARY KEY (workspace_id, workspace_member_id, project_id, file_path, publication_revision) ); CREATE TABLE IF NOT EXISTS published_comment_backfill_members ( @@ -54,6 +57,10 @@ export function migratePublishedCommentBackfillState(db: SqliteDb): void { PRIMARY KEY (workspace_id, workspace_member_id, project_id, file_path, publication_revision, comment_id) ); `); + const columns = db.prepare("PRAGMA table_info(published_comment_backfill_batches)").all() as Array<{ name: string }>; + if (!columns.some(column => column.name === "reopened")) { + db.exec("ALTER TABLE published_comment_backfill_batches ADD COLUMN reopened INTEGER CHECK (reopened IN (0, 1))"); + } } function currentPublicationMatches(db: SqliteDb, record: PublishedCommentBackfillOutboxRecord): boolean { @@ -84,12 +91,13 @@ export function recordPublishedCommentBackfill(db: SqliteDb, input: RecordPublis if (!publicationRevision.token) throw new Error('Published comment backfill requires a publication revision'); const commentIds = [...new Set(input.commentIds)]; const inserted = db.prepare(`INSERT INTO published_comment_backfill_batches - (workspace_id, workspace_member_id, project_id, file_path, publication_revision, state, retryable, code) - VALUES (?, ?, ?, ?, ?, ?, 0, NULL) + (workspace_id, workspace_member_id, project_id, file_path, publication_revision, state, retryable, code, reopened) + VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?) ON CONFLICT(workspace_id, workspace_member_id, project_id, file_path, publication_revision) DO NOTHING`).run( scope.resourceTeamId, scope.ownerMemberId, scope.projectId, scope.filePath, publicationRevision.token, commentIds.length === 0 ? 'succeeded' : 'pending', + input.reopened === undefined ? null : input.reopened ? 1 : 0, ); if (!inserted.changes) return; const insertMember = db.prepare(`INSERT OR IGNORE INTO published_comment_backfill_members @@ -154,16 +162,17 @@ export function readPublishedCommentBackfill( subject.workspaceId, subject.workspaceMemberId, subject.projectId, subject.filePath, ) as { revision?: unknown } | undefined; if (!current || typeof current.revision !== 'string' || !current.revision) return undefined; - const row = db.prepare(`SELECT state, retryable, code FROM published_comment_backfill_batches + const row = db.prepare(`SELECT state, retryable, code, reopened FROM published_comment_backfill_batches WHERE workspace_id=? AND workspace_member_id=? AND project_id=? AND file_path=? AND publication_revision=?`).get( subject.workspaceId, subject.workspaceMemberId, subject.projectId, subject.filePath, current.revision, - ) as { state?: unknown; retryable?: unknown; code?: unknown } | undefined; + ) as { state?: unknown; retryable?: unknown; code?: unknown; reopened?: unknown } | undefined; if (!row || (row.state !== 'pending' && row.state !== 'succeeded' && row.state !== 'failed')) return undefined; const result: CommentBackfillState = { state: row.state, filePath: subject.filePath, publicationRevision: current.revision, retryable: row.retryable === 1, + ...(row.reopened === 0 || row.reopened === 1 ? { reopened: row.reopened === 1 } : {}), }; return typeof row.code === 'string' && row.code ? { ...result, code: row.code } : result; } diff --git a/apps/daemon/src/collab/published-comment-mutation.ts b/apps/daemon/src/collab/published-comment-mutation.ts new file mode 100644 index 0000000000..bf7dabae7a --- /dev/null +++ b/apps/daemon/src/collab/published-comment-mutation.ts @@ -0,0 +1,31 @@ +import type Database from 'better-sqlite3'; +import type { PreviewComment, WorkspaceCollabContext } from '@open-design/contracts'; +import { getWorkspaceProjectByProjectId } from '../db.js'; +import { previewCommentToCloud } from './collab-cloud-service.js'; +import { recordPublishedCommentMutation } from './comment-relay-publication-mapping.js'; + +/** Store only owner-authored-project mutations for the exact historical personal publication mapping. */ +export function recordPersonalPublishedCommentMutation( + db: Database.Database, + comment: PreviewComment, + context: WorkspaceCollabContext | null, + deleted: boolean, +): boolean { + if (!context) return true; + const binding = getWorkspaceProjectByProjectId(db, comment.projectId); + if (!binding || binding.visibility !== 'personal' || binding.workspaceId !== context.workspaceId + || binding.createdByWorkspaceMemberId !== context.workspaceMemberId || binding.resourceState === 'deleted') return true; + const scope = { + resourceTeamId: binding.workspaceId, + ownerMemberId: context.workspaceMemberId, + projectId: comment.projectId, + filePath: comment.filePath, + }; + // Journal active and stopped aliases: stop can race the ordinary outbox acknowledgement. + const payload = previewCommentToCloud(comment, ''); + // Ensure resumed lifecycle events outrank the record delivered before stop. + payload.updatedAt = Math.max(Date.now(), payload.updatedAt + 1); + if (deleted) payload.deleted = true; + recordPublishedCommentMutation(db, scope, payload); + return true; +} diff --git a/apps/daemon/src/collab/published-file-comment-backfill.ts b/apps/daemon/src/collab/published-file-comment-backfill.ts index c04f6cbfee..0e711398d2 100644 --- a/apps/daemon/src/collab/published-file-comment-backfill.ts +++ b/apps/daemon/src/collab/published-file-comment-backfill.ts @@ -3,7 +3,7 @@ import type { PreviewComment } from '@open-design/contracts'; import { ensureProjectCommentAnchorConversation, getProjectPreviewComment, getWorkspaceProjectByProjectId, isProjectCommentAnchorConversationId } from '../db.js'; import { previewCommentToCloud } from './collab-cloud-service.js'; import { createCommentRelayOutboxStore } from './comment-relay-outbox.js'; -import { recordCommentRelayPublicationMapping } from './comment-relay-publication-mapping.js'; +import { carryPublishedCommentMutationsToRevision, listPublishedCommentMutations, recordCommentRelayPublicationMapping } from './comment-relay-publication-mapping.js'; import { recordPublishedCommentBackfill } from './published-comment-backfill-state.js'; import { createSqlitePublicFilePublicationStore, type PublicFilePublicationScope, type PublicFilePublicationRevision } from './public-file-publication-store.js'; @@ -12,6 +12,8 @@ export interface PublishedFileCommentBackfillInput { publicationRevision: PublicFilePublicationRevision; /** Produced by the publisher's actual file mapping; never guessed here. */ publicFilePath: string; + /** From the verified publication completion, not a local URL guess. */ + reopened?: boolean; } /** @@ -45,7 +47,8 @@ export function enqueuePublishedFileComments( throw new Error('Publication comment anchor unavailable'); } const outbox = createCommentRelayOutboxStore(db); - recordCommentRelayPublicationMapping(db, scope, { ...publicationRevision, publicFilePath }); + const pendingMutations = listPublishedCommentMutations(db, scope, publicationRevision.slug); + const pendingMutationIds = new Set(pendingMutations.map(comment => comment.id)); // Filter in SQL, not by scanning every file and later deciding to disclose it. const ids = db.prepare(`SELECT id FROM preview_comments WHERE project_id = ? AND file_path = ? ORDER BY created_at, rowid`) @@ -64,6 +67,13 @@ export function enqueuePublishedFileComments( // SAFETY: DB normalizer supplies the PreviewComment shape; authorless legacy rows // remain authorless in the wire converter, never assigned to the publisher. const comment = previewCommentToCloud(stored as unknown as PreviewComment, ''); + // The journal is the authoritative latest mutation for an already-published + // comment. Enqueuing the older local snapshot first would rotate its durable + // event key twice during each resume (and duplicate a committed remote event). + if (pendingMutationIds.has(comment.id)) { + commentIds.push(comment.id); + continue; + } outbox.enqueue({ workspaceId: scope.resourceTeamId, workspaceMemberId: scope.ownerMemberId, teamId: scope.resourceTeamId, relayScope: binding.visibility, @@ -74,8 +84,31 @@ export function enqueuePublishedFileComments( commentIds.push(comment.id); enqueued += 1; } + // Reconcile changes to comments that were already published before the stop. + // Current rows above provide new additions; these durable latest snapshots + // cover status edits and deleted rows. The outbox key coalesces same-id rows. + for (const comment of pendingMutations) { + outbox.enqueue({ + workspaceId: scope.resourceTeamId, workspaceMemberId: scope.ownerMemberId, + teamId: scope.resourceTeamId, relayScope: binding.visibility, + projectId: scope.projectId, expectedOwnerMemberId: binding.createdByWorkspaceMemberId, + comment, + publication: { ...publicationRevision, publicFilePath }, + }); + // A stopped-period deletion has no surviving preview_comments row above. It + // still belongs to this publication's initial reconciliation batch: a + // failed tombstone must not make comment sync appear succeeded. + commentIds.push(comment.id); + enqueued += 1; + } + // The outbox can be cancelled by another stop before delivery. Carry the + // journal to this witness and clear each change only on its remote receipt. + carryPublishedCommentMutationsToRevision(db, scope, publicationRevision); + recordCommentRelayPublicationMapping(db, scope, { ...publicationRevision, publicFilePath }); // The queue writes above and this exact initial membership are one SQLite // transaction: no published file can silently lack a backfill result. - recordPublishedCommentBackfill(db, { scope, publicationRevision, commentIds }); + recordPublishedCommentBackfill(db, { scope, publicationRevision, commentIds, + ...(input.reopened !== undefined ? { reopened: input.reopened } : {}), + }); return { enqueued, skippedInbound }; } diff --git a/apps/daemon/src/collab/share-publication-completion.ts b/apps/daemon/src/collab/share-publication-completion.ts index dcdbd78cd3..5f380b263c 100644 --- a/apps/daemon/src/collab/share-publication-completion.ts +++ b/apps/daemon/src/collab/share-publication-completion.ts @@ -11,6 +11,8 @@ export interface SharePublicationCompletionInput { publication: PublicFilePublication; mapping: ShareFileMapping; result: SharePublishResult; + /** Set only by the HTTP route after a remote stopped-alias witness and resume receipt. */ + reopened?: boolean; } /** Persist local witnesses and both kinds of intent in one transaction, after @@ -25,7 +27,7 @@ export function createSharePublicationCompletion( retryAvailable = false, ) { const commit = db.transaction((input: SharePublicationCompletionInput): SharePublishResult => { - const revision = recordPublication(input.scope, input.publication, input.mapping); + const revision = recordPublication(input.scope, input.publication, input.mapping, input.reopened === true); if (input.result.status === 'published') return input.result; const pending = recordPendingShareBinding(outbox, { ...input.scope, resourceId: input.resourceId, publicationRevision: revision.token, receipt: input.result.receipt }, retryAvailable, true); diff --git a/apps/daemon/src/collab/vela-cli-collab-client.ts b/apps/daemon/src/collab/vela-cli-collab-client.ts index c181d0b250..235c7dde0b 100644 --- a/apps/daemon/src/collab/vela-cli-collab-client.ts +++ b/apps/daemon/src/collab/vela-cli-collab-client.ts @@ -1,240 +1,320 @@ -import { VELA_CLI_FAILURE_ENVELOPE_FIELDS, type CommentBatchPushRequest, type CommentBatchPushResponse } from '@open-design/contracts'; -import { parseCommentBatchReceipt, validateCommentBatchRequest } from './comment-batch-receipt.js'; -import type { - CollabCloudComment, - CollabCloudMemberDirectoryEntry, - CollabMemberRole, - CollabPresenceMember, -} from '@open-design/contracts'; -import { CollabCloudError } from '../integrations/collab-cloud.js'; import { - runVelaCommand, - velaCommandStdout, - type VelaCommandOptions, - velaWorkspaceCommandOptions, -} from '../integrations/vela-command.js'; + VELA_CLI_FAILURE_ENVELOPE_FIELDS, + type CommentBatchPushRequest, + type CommentBatchPushResponse, +} from "@open-design/contracts"; +import { + parseCommentBatchReceipt, + validateCommentBatchRequest, +} from "./comment-batch-receipt.js"; +import type { + CollabCloudComment, + CollabCloudMemberDirectoryEntry, + CollabMemberRole, + CollabPresenceMember, +} from "@open-design/contracts"; +import { CollabCloudError } from "../integrations/collab-cloud.js"; +import { + runVelaCommand, + velaCommandStdout, + type VelaCommandOptions, + velaWorkspaceCommandOptions, +} from "../integrations/vela-command.js"; export type RunVelaCollab = ( - args: string[], - workspaceId?: string, - options?: Pick, + args: string[], + workspaceId?: string, + options?: Pick, ) => Promise; export interface VelaCliCollabClientOptions { - run?: RunVelaCollab; + run?: RunVelaCollab; } type MemberWire = { - memberId?: unknown; - displayName?: unknown; - role?: unknown; - avatarUrl?: unknown; + memberId?: unknown; + displayName?: unknown; + role?: unknown; + avatarUrl?: unknown; }; type PresenceWire = MemberWire & { - filePath?: unknown; - activity?: unknown; - heartbeatAt?: unknown; + filePath?: unknown; + activity?: unknown; + heartbeatAt?: unknown; }; type PullCommentsWire = { - comments?: unknown; - latestSeq?: unknown; + comments?: unknown; + latestSeq?: unknown; }; /** Retain the service's author snapshot rather than hashing local identity IDs. */ function toCloudComment(value: unknown): CollabCloudComment { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return value as CollabCloudComment; - } - const wire = value as CollabCloudComment & { author?: unknown }; - const author = wire.author && typeof wire.author === 'object' && !Array.isArray(wire.author) - ? wire.author as { authorKey?: unknown; displayName?: unknown } - : null; - if (!author) return wire; - return { - ...wire, - ...(typeof author.authorKey === 'string' && author.authorKey.trim() - ? { authorKey: author.authorKey } - : {}), - ...(typeof author.displayName === 'string' && author.displayName.trim() - ? { authorDisplayName: author.displayName } - : {}), - }; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value as CollabCloudComment; + } + const wire = value as CollabCloudComment & { author?: unknown }; + const author = + wire.author && + typeof wire.author === "object" && + !Array.isArray(wire.author) + ? (wire.author as { authorKey?: unknown; displayName?: unknown }) + : null; + if (!author) return wire; + return { + ...wire, + ...(typeof author.authorKey === "string" && author.authorKey.trim() + ? { authorKey: author.authorKey } + : {}), + ...(typeof author.displayName === "string" && author.displayName.trim() + ? { authorDisplayName: author.displayName } + : {}), + }; } export interface VelaCliPresenceHeartbeatInput { - member: CollabPresenceMember; - clientId?: string; - filePath?: string | null; - activity?: CollabPresenceMember['activity']; + member: CollabPresenceMember; + clientId?: string; + filePath?: string | null; + activity?: CollabPresenceMember["activity"]; } export interface VelaCliPresenceLeaveInput { - memberId: string; - clientId?: string; + memberId: string; + clientId?: string; } -type PresenceActivity = Exclude; +type PresenceActivity = Exclude; -export function createVelaCliCollabClient(options: VelaCliCollabClientOptions = {}) { - const run = options.run ?? defaultRunVelaCollab; +export function createVelaCliCollabClient( + options: VelaCliCollabClientOptions = {}, +) { + const run = options.run ?? defaultRunVelaCollab; - async function runJson(args: string[], workspaceId: string, commandOptions?: Pick): Promise { - const requestedWorkspaceId = workspaceId.trim(); - if (!requestedWorkspaceId) { - throw new Error('explicit workspace scope is required'); - } - let stdout: string; - try { - stdout = await run(args, requestedWorkspaceId, commandOptions); - } catch (error) { - throw collabCloudErrorFromVelaFailure(error) ?? error; - } - const trimmed = stdout.trim(); - if (!trimmed) return {} as T; - try { - return JSON.parse(trimmed) as T; - } catch (cause) { - throw new Error('Invalid JSON from Vela collaboration command', { cause }); - } - } + async function runJson( + args: string[], + workspaceId: string, + commandOptions?: Pick, + ): Promise { + const requestedWorkspaceId = workspaceId.trim(); + if (!requestedWorkspaceId) { + throw new Error("explicit workspace scope is required"); + } + let stdout: string; + try { + stdout = await run(args, requestedWorkspaceId, commandOptions); + } catch (error) { + throw collabCloudErrorFromVelaFailure(error) ?? error; + } + const trimmed = stdout.trim(); + if (!trimmed) return {} as T; + try { + return JSON.parse(trimmed) as T; + } catch (cause) { + throw new Error("Invalid JSON from Vela collaboration command", { + cause, + }); + } + } - return { - isConfigured(): boolean { - return true; - }, + return { + isConfigured(): boolean { + return true; + }, - async registerMember( - _teamId: string, - _memberId: string, - input: { displayName: string; role: CollabMemberRole }, - ): Promise { - const args = ['member', 'register', '--display-name', input.displayName, '--role', input.role]; - const payload = await runJson<{ member?: MemberWire }>(args, _teamId); - return toDirectoryEntry(payload.member); - }, + async registerMember( + _teamId: string, + _memberId: string, + input: { displayName: string; role: CollabMemberRole }, + ): Promise { + const args = [ + "member", + "register", + "--display-name", + input.displayName, + "--role", + input.role, + ]; + const payload = await runJson<{ member?: MemberWire }>(args, _teamId); + return toDirectoryEntry(payload.member); + }, - async listMembers(_teamId: string): Promise { - const payload = await runJson<{ members?: MemberWire[] }>( - ['member', 'list'], - _teamId, - ); - return Array.isArray(payload.members) ? payload.members.map(toDirectoryEntry) : []; - }, + async listMembers( + _teamId: string, + ): Promise { + const payload = await runJson<{ members?: MemberWire[] }>( + ["member", "list"], + _teamId, + ); + return Array.isArray(payload.members) + ? payload.members.map(toDirectoryEntry) + : []; + }, - async pushCommentBatch(workspaceId: string, projectId: string, request: CommentBatchPushRequest): Promise { - if (!workspaceId.trim() || !projectId.trim()) throw new Error('explicit batch scope is required'); - validateCommentBatchRequest(request); - // Snapshot before awaiting the process: correlation cannot follow caller mutation. - const captured: CommentBatchPushRequest = { comments: request.comments.map(item => ({ ...item })) }; - const input = JSON.stringify(captured); - let stdout: string; - try { - stdout = await run(['comment', 'push-batch', projectId, '--comment-file', '-', '--mode', 'align', '--json'], - workspaceId.trim(), { input }); - } catch (error) { - stdout = velaCommandStdout(error); - if (!stdout.trim()) throw error; - } - return parseCommentBatchReceipt(stdout, captured); - }, + async pushCommentBatch( + workspaceId: string, + projectId: string, + request: CommentBatchPushRequest, + ): Promise { + if (!workspaceId.trim() || !projectId.trim()) + throw new Error("explicit batch scope is required"); + validateCommentBatchRequest(request); + // Snapshot before awaiting the process: correlation cannot follow caller mutation. + const captured: CommentBatchPushRequest = { + comments: request.comments.map((item) => ({ ...item })), + }; + const input = JSON.stringify(captured); + let stdout: string; + try { + stdout = await run( + [ + "comment", + "push-batch", + projectId, + "--comment-file", + "-", + "--mode", + "align", + "--json", + ], + workspaceId.trim(), + { input }, + ); + } catch (error) { + stdout = velaCommandStdout(error); + if (!stdout.trim()) throw error; + } + return parseCommentBatchReceipt(stdout, captured); + }, - async pushComment( - _teamId: string, - projectId: string, - comment: CollabCloudComment, - ): Promise<{ seq: number }> { - const payload = await runJson<{ seq?: unknown; authorKey?: unknown; author?: { authorKey?: unknown } }>([ - 'comment', - 'push', - projectId, - '--comment-file', - '-', - ], _teamId, { input: JSON.stringify(comment) }); - const authorKey = payload.author?.authorKey ?? payload.authorKey; - return { - seq: typeof payload.seq === 'number' ? payload.seq : 0, - ...(typeof authorKey === 'string' && authorKey.trim() ? { authorKey: authorKey.trim() } : {}), - }; - }, + async pushComment( + _teamId: string, + projectId: string, + comment: CollabCloudComment, + idempotencyKey?: string, + historicalAuthorMemberId?: string, + ): Promise<{ seq: number }> { + const payload = await runJson<{ + seq?: unknown; + authorKey?: unknown; + author?: { authorKey?: unknown }; + }>( + [ + "comment", + "push", + projectId, + "--comment-file", + "-", + ...(idempotencyKey ? ["--idempotency-key", idempotencyKey] : []), + ...(historicalAuthorMemberId ? ["--historical-author-member-id", historicalAuthorMemberId] : []), + ], + _teamId, + { input: JSON.stringify(comment) }, + ); + const authorKey = payload.author?.authorKey ?? payload.authorKey; + return { + seq: typeof payload.seq === "number" ? payload.seq : 0, + ...(typeof authorKey === "string" && authorKey.trim() + ? { authorKey: authorKey.trim() } + : {}), + }; + }, - async pullComments( - _teamId: string, - projectId: string, - sinceSeq: number, - ): Promise<{ - comments: CollabCloudComment[]; - latestSeq: number; - notModified: boolean; - etag: string | null; - }> { - const payload = await runJson([ - 'comment', - 'pull', - projectId, - '--since-seq', - String(sinceSeq), - '--author-kinds', - 'member,user', - ], _teamId); - const comments = Array.isArray(payload.comments) - ? payload.comments.map(toCloudComment) - : []; - return { - comments, - latestSeq: typeof payload.latestSeq === 'number' ? payload.latestSeq : sinceSeq, - notModified: comments.length === 0, - etag: null, - }; - }, + async pullComments( + _teamId: string, + projectId: string, + sinceSeq: number, + ): Promise<{ + comments: CollabCloudComment[]; + latestSeq: number; + notModified: boolean; + etag: string | null; + }> { + const payload = await runJson( + [ + "comment", + "pull", + projectId, + "--since-seq", + String(sinceSeq), + "--author-kinds", + "member,user", + ], + _teamId, + ); + const comments = Array.isArray(payload.comments) + ? payload.comments.map(toCloudComment) + : []; + return { + comments, + latestSeq: + typeof payload.latestSeq === "number" ? payload.latestSeq : sinceSeq, + notModified: comments.length === 0, + etag: null, + }; + }, - async heartbeatPresence( - projectId: string, - input: VelaCliPresenceHeartbeatInput, - workspaceId: string, - ): Promise { - const args = [ - 'presence', - 'heartbeat', - projectId, - '--client-id', - input.clientId ?? input.member.memberId, - ]; - const displayName = input.member.name?.trim(); - if (displayName) args.push('--display-name', displayName); - if (input.filePath) args.push('--file-path', input.filePath); - if (input.activity !== undefined && input.activity !== null) { - args.push('--activity-json', JSON.stringify(input.activity)); - } - const payload = await runJson<{ viewers?: PresenceWire[] }>(args, workspaceId); - return Array.isArray(payload.viewers) ? payload.viewers.map(toPresenceMember) : []; - }, + async heartbeatPresence( + projectId: string, + input: VelaCliPresenceHeartbeatInput, + workspaceId: string, + ): Promise { + const args = [ + "presence", + "heartbeat", + projectId, + "--client-id", + input.clientId ?? input.member.memberId, + ]; + const displayName = input.member.name?.trim(); + if (displayName) args.push("--display-name", displayName); + if (input.filePath) args.push("--file-path", input.filePath); + if (input.activity !== undefined && input.activity !== null) { + args.push("--activity-json", JSON.stringify(input.activity)); + } + const payload = await runJson<{ viewers?: PresenceWire[] }>( + args, + workspaceId, + ); + return Array.isArray(payload.viewers) + ? payload.viewers.map(toPresenceMember) + : []; + }, - async listPresence(projectId: string, workspaceId: string): Promise { - const payload = await runJson<{ viewers?: PresenceWire[] }>([ - 'presence', - 'list', - projectId, - ], workspaceId); - return Array.isArray(payload.viewers) ? payload.viewers.map(toPresenceMember) : []; - }, + async listPresence( + projectId: string, + workspaceId: string, + ): Promise { + const payload = await runJson<{ viewers?: PresenceWire[] }>( + ["presence", "list", projectId], + workspaceId, + ); + return Array.isArray(payload.viewers) + ? payload.viewers.map(toPresenceMember) + : []; + }, - async leavePresence( - projectId: string, - input: VelaCliPresenceLeaveInput, - workspaceId: string, - ): Promise { - const payload = await runJson<{ viewers?: PresenceWire[] }>([ - 'presence', - 'leave', - projectId, - '--client-id', - input.clientId ?? input.memberId, - ], workspaceId); - return Array.isArray(payload.viewers) ? payload.viewers.map(toPresenceMember) : []; - }, - }; + async leavePresence( + projectId: string, + input: VelaCliPresenceLeaveInput, + workspaceId: string, + ): Promise { + const payload = await runJson<{ viewers?: PresenceWire[] }>( + [ + "presence", + "leave", + projectId, + "--client-id", + input.clientId ?? input.memberId, + ], + workspaceId, + ); + return Array.isArray(payload.viewers) + ? payload.viewers.map(toPresenceMember) + : []; + }, + }; } export type VelaCliCollabClient = ReturnType; @@ -246,75 +326,94 @@ export type VelaCliCollabClient = ReturnType; * nested `error.code` remains accepted; both fields are required so a code * alone cannot cancel data. */ -export function collabCloudErrorFromVelaFailure(error: unknown): CollabCloudError | null { - const stdout = error !== null && typeof error === 'object' - && typeof (error as { stdout?: unknown }).stdout === 'string' - ? (error as { stdout: string }).stdout.trim() - : ''; - if (!stdout) return null; - let envelope: unknown; - try { - envelope = JSON.parse(stdout); - } catch { - return null; - } - if (envelope === null || typeof envelope !== 'object' || Array.isArray(envelope)) return null; - const root = envelope as Record; - const nested = root[VELA_CLI_FAILURE_ENVELOPE_FIELDS.message]; - const detail = nested !== null && typeof nested === 'object' && !Array.isArray(nested) - ? nested as Record - : null; - const status = detail?.[VELA_CLI_FAILURE_ENVELOPE_FIELDS.status] - ?? root[VELA_CLI_FAILURE_ENVELOPE_FIELDS.status]; - const code = detail?.code ?? root[VELA_CLI_FAILURE_ENVELOPE_FIELDS.code]; - const message = detail?.message ?? root[VELA_CLI_FAILURE_ENVELOPE_FIELDS.message]; - if (typeof status !== 'number' || !Number.isInteger(status) || typeof code !== 'string' || !code.trim()) return null; - return new CollabCloudError( - status, - code, - typeof message === 'string' ? message : undefined, - ); +export function collabCloudErrorFromVelaFailure( + error: unknown, +): CollabCloudError | null { + const stdout = + error !== null && + typeof error === "object" && + typeof (error as { stdout?: unknown }).stdout === "string" + ? (error as { stdout: string }).stdout.trim() + : ""; + if (!stdout) return null; + let envelope: unknown; + try { + envelope = JSON.parse(stdout); + } catch { + return null; + } + if ( + envelope === null || + typeof envelope !== "object" || + Array.isArray(envelope) + ) + return null; + const root = envelope as Record; + const nested = root[VELA_CLI_FAILURE_ENVELOPE_FIELDS.message]; + const detail = + nested !== null && typeof nested === "object" && !Array.isArray(nested) + ? (nested as Record) + : null; + const status = + detail?.[VELA_CLI_FAILURE_ENVELOPE_FIELDS.status] ?? + root[VELA_CLI_FAILURE_ENVELOPE_FIELDS.status]; + const code = detail?.code ?? root[VELA_CLI_FAILURE_ENVELOPE_FIELDS.code]; + const message = + detail?.message ?? root[VELA_CLI_FAILURE_ENVELOPE_FIELDS.message]; + if ( + typeof status !== "number" || + !Number.isInteger(status) || + typeof code !== "string" || + !code.trim() + ) + return null; + return new CollabCloudError( + status, + code, + typeof message === "string" ? message : undefined, + ); } -function toDirectoryEntry(input: MemberWire | undefined): CollabCloudMemberDirectoryEntry { - const memberId = typeof input?.memberId === 'string' ? input.memberId : ''; - const displayName = - typeof input?.displayName === 'string' && input.displayName.trim() - ? input.displayName - : memberId; - const role = isRole(input?.role) ? input.role : 'member'; - return { memberId, displayName, role }; +function toDirectoryEntry( + input: MemberWire | undefined, +): CollabCloudMemberDirectoryEntry { + const memberId = typeof input?.memberId === "string" ? input.memberId : ""; + const displayName = + typeof input?.displayName === "string" && input.displayName.trim() + ? input.displayName + : memberId; + const role = isRole(input?.role) ? input.role : "member"; + return { memberId, displayName, role }; } function toPresenceMember(input: PresenceWire): CollabPresenceMember { - const memberId = typeof input.memberId === 'string' ? input.memberId : ''; - const member: CollabPresenceMember = { - memberId, - }; - const displayName = typeof input.displayName === 'string' - ? input.displayName.trim() - : ''; - if (displayName && displayName !== memberId) { - member.name = displayName; - } - if (isRole(input.role)) member.role = input.role; - if (typeof input.avatarUrl === 'string' || input.avatarUrl === null) { - member.avatarUrl = input.avatarUrl; - } - if (typeof input.filePath === 'string' || input.filePath === null) { - member.filePath = input.filePath; - } - if (input.activity !== undefined) { - member.activity = input.activity as PresenceActivity; - } - if (typeof input.heartbeatAt === 'string') { - member.heartbeatAt = input.heartbeatAt; - } - return member; + const memberId = typeof input.memberId === "string" ? input.memberId : ""; + const member: CollabPresenceMember = { + memberId, + }; + const displayName = + typeof input.displayName === "string" ? input.displayName.trim() : ""; + if (displayName && displayName !== memberId) { + member.name = displayName; + } + if (isRole(input.role)) member.role = input.role; + if (typeof input.avatarUrl === "string" || input.avatarUrl === null) { + member.avatarUrl = input.avatarUrl; + } + if (typeof input.filePath === "string" || input.filePath === null) { + member.filePath = input.filePath; + } + if (input.activity !== undefined) { + member.activity = input.activity as PresenceActivity; + } + if (typeof input.heartbeatAt === "string") { + member.heartbeatAt = input.heartbeatAt; + } + return member; } function isRole(value: unknown): value is CollabMemberRole { - return value === 'owner' || value === 'admin' || value === 'member'; + return value === "owner" || value === "admin" || value === "member"; } /** @@ -332,60 +431,79 @@ const COMMENT_COMMAND_TIMEOUT_MS = 30_000; /** Only a validated CLI envelope may supply status/code; omitted Go status stays null. */ function commentCommandError(error: unknown): Error { - const classified = collabCloudErrorFromVelaFailure(error); - if (classified) return classified; + const classified = collabCloudErrorFromVelaFailure(error); + if (classified) return classified; - const original = error instanceof Error - ? error - : new Error('Vela comment command failed', { cause: error }); - let payload: unknown; - try { payload = JSON.parse(velaCommandStdout(error)); } catch { return original; } - if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return original; - const wire = payload as Record; - const message = wire[VELA_CLI_FAILURE_ENVELOPE_FIELDS.message]; - const code = wire[VELA_CLI_FAILURE_ENVELOPE_FIELDS.code]; - const statusValue = wire[VELA_CLI_FAILURE_ENVELOPE_FIELDS.status]; - const status = typeof statusValue === 'number' && Number.isInteger(statusValue) - && statusValue >= 400 && statusValue <= 599 - ? statusValue - : null; - if (typeof message !== 'string' || !message.trim() || - typeof code !== 'string' || !/^[A-Za-z0-9_-]+$/.test(code)) return original; - return Object.assign(new Error(message, { cause: original }), { status, code }); + const original = + error instanceof Error + ? error + : new Error("Vela comment command failed", { cause: error }); + let payload: unknown; + try { + payload = JSON.parse(velaCommandStdout(error)); + } catch { + return original; + } + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) + return original; + const wire = payload as Record; + const message = wire[VELA_CLI_FAILURE_ENVELOPE_FIELDS.message]; + const code = wire[VELA_CLI_FAILURE_ENVELOPE_FIELDS.code]; + const statusValue = wire[VELA_CLI_FAILURE_ENVELOPE_FIELDS.status]; + const status = + typeof statusValue === "number" && + Number.isInteger(statusValue) && + statusValue >= 400 && + statusValue <= 599 + ? statusValue + : null; + if ( + typeof message !== "string" || + !message.trim() || + typeof code !== "string" || + !/^[A-Za-z0-9_-]+$/.test(code) + ) + return original; + return Object.assign(new Error(message, { cause: original }), { + status, + code, + }); } const defaultRunVelaCollab: RunVelaCollab = (args, workspaceId, options) => - runVelaCommand( - ['collab', ...args], - { - ...velaWorkspaceCommandOptions(workspaceId), - ...options, - ...(args[0] === 'presence' - ? { timeoutMs: PRESENCE_COMMAND_TIMEOUT_MS } - : args[0] === 'comment' && (args[1] === 'push' || args[1] === 'pull' || args[1] === 'push-batch') - ? { timeoutMs: COMMENT_COMMAND_TIMEOUT_MS } - : {}), - }, - ).catch((error: unknown) => { - throw args[0] === 'comment' && args[1] === 'push' ? commentCommandError(error) : error; - }); + runVelaCommand(["collab", ...args], { + ...velaWorkspaceCommandOptions(workspaceId), + ...options, + ...(args[0] === "presence" + ? { timeoutMs: PRESENCE_COMMAND_TIMEOUT_MS } + : args[0] === "comment" && + (args[1] === "push" || args[1] === "pull" || args[1] === "push-batch") + ? { timeoutMs: COMMENT_COMMAND_TIMEOUT_MS } + : {}), + }).catch((error: unknown) => { + throw args[0] === "comment" && args[1] === "push" + ? commentCommandError(error) + : error; + }); export function shouldUseVelaCliCollabTransport( - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = process.env, ): boolean { - if (env.OD_WORKSPACE_CONTEXT_SOURCE?.trim() === 'vela') return true; - const explicitTransport = env.OD_COLLAB_TRANSPORT?.trim(); - if (explicitTransport) return explicitTransport === 'vela-cli'; - if (env.OD_COLLAB_CLOUD_URL?.trim()) return false; - return env.OD_TEAM_PROJECTS_TRANSPORT?.trim() === 'vela-cli' || - env.OD_RESOURCE_TRANSPORT?.trim() === 'vela-cli'; + if (env.OD_WORKSPACE_CONTEXT_SOURCE?.trim() === "vela") return true; + const explicitTransport = env.OD_COLLAB_TRANSPORT?.trim(); + if (explicitTransport) return explicitTransport === "vela-cli"; + if (env.OD_COLLAB_CLOUD_URL?.trim()) return false; + return ( + env.OD_TEAM_PROJECTS_TRANSPORT?.trim() === "vela-cli" || + env.OD_RESOURCE_TRANSPORT?.trim() === "vela-cli" + ); } export function createVelaCliCollabClientFromEnv( - env: NodeJS.ProcessEnv = process.env, - options: Omit = {}, + env: NodeJS.ProcessEnv = process.env, + options: Omit = {}, ): VelaCliCollabClient | null { - return shouldUseVelaCliCollabTransport(env) - ? createVelaCliCollabClient(options) - : null; + return shouldUseVelaCliCollabTransport(env) + ? createVelaCliCollabClient(options) + : null; } diff --git a/apps/daemon/src/collab/vela-project-share-state.ts b/apps/daemon/src/collab/vela-project-share-state.ts index 20370c3eaf..08e9f6d0c3 100644 --- a/apps/daemon/src/collab/vela-project-share-state.ts +++ b/apps/daemon/src/collab/vela-project-share-state.ts @@ -15,6 +15,8 @@ export function createVelaProjectShareState(options: { runCommand?: typeof runPinnedVelaCommand; /** Persisted local authorship, never inferred from the current reader. */ resolveLocalProjectOwner?: (projectId: string, workspaceId: string) => string | null; + /** K3 status GET must never upsert a missing personal catalog row. */ + registerPersonalProject?: boolean; }): ReadProjectShareState { return async scope => { const identity = Object.freeze({ ...scope }); @@ -31,7 +33,7 @@ export function createVelaProjectShareState(options: { // Omitted sync/version/metadata fields preserve any existing cloud state. const personalWorkspace = directory.items.find(item => item.workspaceId === identity.resourceTeamId && item.workspaceMemberId === identity.ownerMemberId && item.workspaceType === 'personal'); - if (personalWorkspace + if (options.registerPersonalProject !== false && personalWorkspace && options.resolveLocalProjectOwner?.(identity.projectId, identity.resourceTeamId) === identity.ownerMemberId) { await (options.runCommand ?? runPinnedVelaCommand)({ args: ['team-projects', 'upsert', identity.projectId, '--resource-id', projectResourceIdFor(identity.projectId, diff --git a/apps/daemon/src/collab/vela-share-binding.ts b/apps/daemon/src/collab/vela-share-binding.ts index 9c260d34ca..5a495e69f7 100644 --- a/apps/daemon/src/collab/vela-share-binding.ts +++ b/apps/daemon/src/collab/vela-share-binding.ts @@ -1,3 +1,7 @@ +import type { SharePublishReceipt } from '@open-design/contracts'; +import { confirmedReceipt } from './share-binding-outbox.js'; +import type { PublicFilePublicationScope } from './public-file-publication-store.js'; +import { publicFileResourceIdFor } from './public-file-resource-id.js'; import { runVelaCommand, velaWorkspaceCommandOptions } from '../integrations/vela-command.js'; export interface VelaShareBindingInput { @@ -25,6 +29,44 @@ export async function resumeVelaShareVersion(input: VelaShareBindingInput, run: return completeVelaShareVersion('resume', input, run); } +/** Reopen an Owner-stopped alias at the immutable version already held by Vela. + * The authenticated command reads the exact published source/version under an + * alias generation lock. Neither OD nor the caller may supply a guessed version. + */ +export async function resumeExistingVelaShare( + scope: PublicFilePublicationScope, + slug: string, + run: typeof runVelaCommand = runVelaCommand, +): Promise { + try { + const identity = Object.freeze({ ...scope }); + const resourceId = publicFileResourceIdFor(identity); + if (!slug.trim() || !identity.projectId.trim() || !identity.filePath.trim() || !identity.resourceTeamId.trim()) { + throw new Error('invalid stopped share identity'); + } + const stdout = await run(['share', 'resume-existing', slug, + '--project-id', identity.projectId, '--source-file-path', identity.filePath, '--json'], + { ...velaWorkspaceCommandOptions(identity.resourceTeamId), timeoutMs: 30_000 }); + const value: unknown = JSON.parse(stdout); + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid resume receipt'); + const record = value as Record; + if (record.status !== 'active' || record.projectId !== identity.projectId || record.slug !== slug + || record.resourceId !== resourceId || typeof record.versionId !== 'string' + || typeof record.entryPath !== 'string' || typeof record.publishedAt !== 'number' + || typeof record.version !== 'number') throw new Error('unverified resume receipt'); + const receipt = confirmedReceipt({ filePath: identity.filePath, slug, + version: record.version, versionId: record.versionId, + entryPath: record.entryPath, publishedAt: record.publishedAt }); + if (receipt.entryPath.includes('\\') || receipt.entryPath.split('/').some(part => !part || part === '.' || part === '..')) { + throw new Error('unsafe original entry path'); + } + return receipt; + } catch { + // Error payloads can include credentials; callers receive a bounded code only. + throw new Error('PUBLIC_SHARE_RESUME_FAILED'); + } +} + async function completeVelaShareVersion(operation: 'bind' | 'resume', input: VelaShareBindingInput, run: typeof runVelaCommand): Promise { try { const request = Object.freeze({ ...input }); diff --git a/apps/daemon/src/collab/vela-share-publish.ts b/apps/daemon/src/collab/vela-share-publish.ts index 47bfa341cf..fd20e96ffa 100644 --- a/apps/daemon/src/collab/vela-share-publish.ts +++ b/apps/daemon/src/collab/vela-share-publish.ts @@ -36,9 +36,9 @@ export async function publishReservedVelaShareVersion( }, run); } -/** Go share publish advances the alias and registers the project binding. - * Accept only a receipt for this exact alias, entry and immutable upload. - * This returns content metadata, not proof of lifecycle status or reachability. +/** Go share publish atomically advances the alias and binds the project. + * Accept only a committed receipt for this exact alias, entry and immutable upload. + * A two-step CLI binary returning binding_pending cannot satisfy this contract. */ export async function publishVelaShareVersion( input: VelaSharePublishInput, @@ -81,13 +81,8 @@ export async function publishVelaShareVersion( || confirmed.publishedAt !== record.publishedAt) throw new Error('inconsistent receipt'); const receipt = { filePath: request.filePath, slug: request.slug, version: record.version, versionId: request.versionId, publishedAt: record.publishedAt, entryPath: request.entryPath }; - if (record.status === 'published') return { status: 'published', receipt }; - if (record.status !== 'binding_pending') throw new Error('unknown publish outcome'); - const binding = record.binding; - const code = binding && typeof binding === 'object' && 'code' in binding ? binding.code : undefined; - const safeCodes = ['UNAUTHENTICATED', 'FORBIDDEN', 'SHARE_NOT_FOUND', 'SHARE_GENERATION_MISMATCH', 'SHARE_BINDING_UNAVAILABLE', 'SHARE_BINDING_STOPPED']; - return { status: 'binding_pending', receipt, binding: { retrying: false, - code: typeof code === 'string' && safeCodes.includes(code) ? code : 'SHARE_BINDING_UNAVAILABLE' } }; + if (record.status !== 'published') throw new Error('unconfirmed publish outcome'); + return { status: 'published', receipt }; } catch { // Child diagnostics can include upstream bodies. No fallback to snapshots // or implicit retry: the remote pointer may already have advanced. diff --git a/apps/daemon/src/integrations/collab-cloud.ts b/apps/daemon/src/integrations/collab-cloud.ts index 2cf2456c8c..fbcec9d90b 100644 --- a/apps/daemon/src/integrations/collab-cloud.ts +++ b/apps/daemon/src/integrations/collab-cloud.ts @@ -9,189 +9,219 @@ // hub verifies B's signed token, this stub presents a shared local token. import type { - CollabCloudComment, - CollabCloudMemberDirectoryEntry, - CollabMemberRole, -} from '@open-design/contracts'; + CollabCloudComment, + CollabCloudMemberDirectoryEntry, + CollabMemberRole, +} from "@open-design/contracts"; const DEFAULT_FETCH_TIMEOUT_MS = 8_000; type FetchLike = typeof fetch; export interface CollabCloudConfig { - baseUrl: string; - token: string | null; + baseUrl: string; + token: string | null; } /** Read the collab-cloud config from env, or null when no URL is configured * (the single "is collab cloud on?" gate — everything degrades to no-op). */ export function readCollabCloudConfig( - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = process.env, ): CollabCloudConfig | null { - const baseUrl = env.OD_COLLAB_CLOUD_URL?.trim(); - if (!baseUrl) return null; - return { baseUrl, token: env.OD_COLLAB_CLOUD_TOKEN?.trim() || null }; + const baseUrl = env.OD_COLLAB_CLOUD_URL?.trim(); + if (!baseUrl) return null; + return { baseUrl, token: env.OD_COLLAB_CLOUD_TOKEN?.trim() || null }; } export function hasExplicitCollabCloudConfig( - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = process.env, ): boolean { - return Boolean(env.OD_COLLAB_CLOUD_URL?.trim()); + return Boolean(env.OD_COLLAB_CLOUD_URL?.trim()); } export class CollabCloudError extends Error { - constructor( - readonly status: number, - readonly code: string, - message?: string, - ) { - super(message ?? `collab cloud error ${status} (${code})`); - this.name = 'CollabCloudError'; - } + constructor( + readonly status: number, + readonly code: string, + message?: string, + ) { + super(message ?? `collab cloud error ${status} (${code})`); + this.name = "CollabCloudError"; + } } export interface CollabCloudMemberRegistration { - displayName: string; - role: CollabMemberRole; + displayName: string; + role: CollabMemberRole; } export interface CollabCloudPullResult { - comments: CollabCloudComment[]; - latestSeq: number; + comments: CollabCloudComment[]; + latestSeq: number; } interface CollabCloudClientOptions { - config?: CollabCloudConfig; - fetch?: FetchLike; - timeoutMs?: number; + config?: CollabCloudConfig; + fetch?: FetchLike; + timeoutMs?: number; } -export function createCollabCloudClient(options: CollabCloudClientOptions = {}) { - const config = options.config ?? readCollabCloudConfig(); - if (!config) { - throw new Error('collab cloud is not configured (OD_COLLAB_CLOUD_URL is unset)'); - } - const fetchImpl = options.fetch ?? fetch; - const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; +export function createCollabCloudClient( + options: CollabCloudClientOptions = {}, +) { + const config = options.config ?? readCollabCloudConfig(); + if (!config) { + throw new Error( + "collab cloud is not configured (OD_COLLAB_CLOUD_URL is unset)", + ); + } + const fetchImpl = options.fetch ?? fetch; + const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; - function authHeaders(extra?: Record): Record { - const headers: Record = { 'content-type': 'application/json', ...extra }; - if (config!.token) headers.authorization = `Bearer ${config!.token}`; - return headers; - } + function authHeaders(extra?: Record): Record { + const headers: Record = { + "content-type": "application/json", + ...extra, + }; + if (config!.token) headers.authorization = `Bearer ${config!.token}`; + return headers; + } - async function request( - method: string, - path: string, - body?: unknown, - extraHeaders?: Record, - ): Promise<{ status: number; payload: T; etag: string | null }> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetchImpl(new URL(path, config!.baseUrl), { - method, - headers: authHeaders(extraHeaders), - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - signal: controller.signal, - }); - const etag = response.headers.get('etag'); - if (response.status === 304) { - return { status: 304, payload: {} as T, etag }; - } - const text = await response.text(); - const payload = text ? JSON.parse(text) : {}; - if (!response.ok) { - const code = typeof payload?.error === 'string' ? payload.error : 'unknown'; - throw new CollabCloudError(response.status, code, payload?.message); - } - return { status: response.status, payload: payload as T, etag }; - } finally { - clearTimeout(timeout); - } - } + async function request( + method: string, + path: string, + body?: unknown, + extraHeaders?: Record, + ): Promise<{ status: number; payload: T; etag: string | null }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(new URL(path, config!.baseUrl), { + method, + headers: authHeaders(extraHeaders), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: controller.signal, + }); + const etag = response.headers.get("etag"); + if (response.status === 304) { + return { status: 304, payload: {} as T, etag }; + } + const text = await response.text(); + const payload = text ? JSON.parse(text) : {}; + if (!response.ok) { + const code = + typeof payload?.error === "string" ? payload.error : "unknown"; + throw new CollabCloudError(response.status, code, payload?.message); + } + return { status: response.status, payload: payload as T, etag }; + } finally { + clearTimeout(timeout); + } + } - return { - isConfigured(): boolean { - return true; - }, + return { + isConfigured(): boolean { + return true; + }, - /** Register (idempotently upsert) a member's directory entry. */ - async registerMember( - teamId: string, - memberId: string, - input: CollabCloudMemberRegistration, - ): Promise { - const { payload } = await request<{ member: CollabCloudMemberDirectoryEntry }>( - 'PUT', - `/teams/${encodeURIComponent(teamId)}/members/${encodeURIComponent(memberId)}`, - input, - ); - return payload.member; - }, + /** Register (idempotently upsert) a member's directory entry. */ + async registerMember( + teamId: string, + memberId: string, + input: CollabCloudMemberRegistration, + ): Promise { + const { payload } = await request<{ + member: CollabCloudMemberDirectoryEntry; + }>( + "PUT", + `/teams/${encodeURIComponent(teamId)}/members/${encodeURIComponent(memberId)}`, + input, + ); + return payload.member; + }, - /** List the team's member directory (memberId → {displayName, role}). */ - async listMembers(teamId: string): Promise { - const { payload } = await request<{ members: CollabCloudMemberDirectoryEntry[] }>( - 'GET', - `/teams/${encodeURIComponent(teamId)}/members`, - ); - return payload.members ?? []; - }, + /** List the team's member directory (memberId → {displayName, role}). */ + async listMembers( + teamId: string, + ): Promise { + const { payload } = await request<{ + members: CollabCloudMemberDirectoryEntry[]; + }>("GET", `/teams/${encodeURIComponent(teamId)}/members`); + return payload.members ?? []; + }, - /** Append a comment to a project's stream; returns the assigned seq. */ - async pushComment( - teamId: string, - projectId: string, - comment: CollabCloudComment, - ): Promise<{ seq: number; authorKey?: string }> { - const { payload } = await request<{ seq: number; authorKey?: string; author?: { authorKey?: string } }>( - 'POST', - `/teams/${encodeURIComponent(teamId)}/projects/${encodeURIComponent(projectId)}/comments`, - { comment }, - ); - const authorKey = payload.author?.authorKey ?? payload.authorKey; - return { seq: payload.seq, ...(authorKey ? { authorKey } : {}) }; - }, + /** Append a comment to a project's stream; returns the assigned seq. */ + async pushComment( + teamId: string, + projectId: string, + comment: CollabCloudComment, + idempotencyKey?: string, + historicalAuthorMemberId?: string, + ): Promise<{ seq: number; authorKey?: string }> { + const { payload } = await request<{ + seq: number; + authorKey?: string; + author?: { authorKey?: string }; + }>( + "POST", + `/teams/${encodeURIComponent(teamId)}/projects/${encodeURIComponent(projectId)}/comments`, + { comment, ...(idempotencyKey ? { idempotencyKey } : {}), + ...(historicalAuthorMemberId ? { historicalAuthorMemberId } : {}) }, + ); + const authorKey = payload.author?.authorKey ?? payload.authorKey; + return { seq: payload.seq, ...(authorKey ? { authorKey } : {}) }; + }, - /** - * Pull comments with `seq > sinceSeq`. `etag` (from a prior pull) enables a - * 304 short-circuit: on 304 the result echoes back `sinceSeq` as `latestSeq` - * with no comments, and `notModified` is true. - */ - async pullComments( - teamId: string, - projectId: string, - sinceSeq: number, - etag?: string | null, - ): Promise { - const query = `?sinceSeq=${encodeURIComponent(String(sinceSeq))}`; - const { status, payload, etag: nextEtag } = await request( - 'GET', - `/teams/${encodeURIComponent(teamId)}/projects/${encodeURIComponent(projectId)}/comments${query}`, - undefined, - etag ? { 'if-none-match': etag } : undefined, - ); - if (status === 304) { - return { comments: [], latestSeq: sinceSeq, notModified: true, etag: nextEtag }; - } - return { - comments: payload.comments ?? [], - latestSeq: typeof payload.latestSeq === 'number' ? payload.latestSeq : sinceSeq, - notModified: false, - etag: nextEtag, - }; - }, - }; + /** + * Pull comments with `seq > sinceSeq`. `etag` (from a prior pull) enables a + * 304 short-circuit: on 304 the result echoes back `sinceSeq` as `latestSeq` + * with no comments, and `notModified` is true. + */ + async pullComments( + teamId: string, + projectId: string, + sinceSeq: number, + etag?: string | null, + ): Promise< + CollabCloudPullResult & { notModified: boolean; etag: string | null } + > { + const query = `?sinceSeq=${encodeURIComponent(String(sinceSeq))}`; + const { + status, + payload, + etag: nextEtag, + } = await request( + "GET", + `/teams/${encodeURIComponent(teamId)}/projects/${encodeURIComponent(projectId)}/comments${query}`, + undefined, + etag ? { "if-none-match": etag } : undefined, + ); + if (status === 304) { + return { + comments: [], + latestSeq: sinceSeq, + notModified: true, + etag: nextEtag, + }; + } + return { + comments: payload.comments ?? [], + latestSeq: + typeof payload.latestSeq === "number" ? payload.latestSeq : sinceSeq, + notModified: false, + etag: nextEtag, + }; + }, + }; } export type CollabCloudClient = ReturnType; /** Build the client from env, or null when the collab cloud is not configured. */ export function createCollabCloudClientFromEnv( - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = process.env, ): CollabCloudClient | null { - const config = readCollabCloudConfig(env); - if (!config) return null; - return createCollabCloudClient({ config }); + const config = readCollabCloudConfig(env); + if (!config) return null; + return createCollabCloudClient({ config }); } diff --git a/apps/daemon/src/routes/collab-sync.ts b/apps/daemon/src/routes/collab-sync.ts index e356e5b6c5..75c608b489 100644 --- a/apps/daemon/src/routes/collab-sync.ts +++ b/apps/daemon/src/routes/collab-sync.ts @@ -9,6 +9,7 @@ import { sharePublishResponse } from '../collab/share-publish-response.js'; import { stopVelaShare } from '../collab/vela-share-stop.js'; import { publicFileResourceIdFor } from '../collab/public-file-resource-id.js'; import { resumePendingShareBinding } from '../collab/resume-pending-share-binding.js'; +import { resumeExistingVelaShare } from '../collab/vela-share-binding.js'; import type { RecordPublicFilePublication } from '../collab/public-file-publication-recording.js'; import type { ShareContentFingerprints } from '../collab/share-content-fingerprint.js'; import { createShareFileMapping, publishedPathForSource, type ShareFileMapping } from '../collab/share-file-mapping.js'; @@ -21,6 +22,7 @@ import { SHARE_MAX_TOTAL_BYTES, type SharePlanSummary, type ShareUnpublishResponse, + type SharePublishRequest, workspaceContextHasWorkspaceIdentity, type ProjectContentTransferState, type ProjectMetadata, @@ -69,6 +71,7 @@ import { type PublicFilePublication, type PublicFilePublicationScope, type PublicFilePublicationStore, + type StopQueuePublicFilePublicationStore, } from '../collab/public-file-publication-store.js'; import { classifyVelaCommandFailure, logPublicFileFailure } from '../collab/public-file-failure.js'; import { clientRequestIdFor } from '../http/client-request-id.js'; @@ -1256,6 +1259,36 @@ export function registerCollabSyncRoutes( return res.json({ ok: true }); }); + app.post(/^\/api\/projects\/([^/]+)\/files\/(.+)\/share-plan$/u, async (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + // SAFETY: Express regex routes expose numeric capture keys at runtime; Express's named-param type omits them. + const params = req.params as unknown as { 0?: string; 1?: string }; + const projectId = String(params[0] ?? ''); + const filePath = normalizePublicFilePath(String(params[1] ?? '')); + if (!projectId || !filePath) return res.status(400).json({ error: 'invalid_file_path' }); + const verification = await verifiedWorkspaceContextForRequest(req, projectId); + if (!verification.ok) return sendWorkspaceVerificationFailure(res, verification); + const context = verification.context; + const principal = publicFilePrincipal(context); + if (!context || !principal) return res.status(409).json(workspaceIdentityRequiredBody()); + if (!await canShareProjectsForRequest(req, context)) return res.status(403).json({ error: 'WORKSPACE_PROJECT_SHARE_DENIED' }); + const resolved = await resolveSharedProjectForPublicFile(resolveSharedProject, projectId, context, principal); + if (!resolved.ok) return res.status(503).json({ error: 'WORKSPACE_PROJECT_OWNERSHIP_UNAVAILABLE' }); + const owner = resolved.project?.ownerMemberId + ?? (!resolved.project ? deps.resolveLocalPublicShareOwner?.(projectId, context.workspaceId) : null); + if (!owner || owner !== principal.memberId || isUnmaterializedSharedPlaceholder(projectStore?.get?.(projectId))) { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_PUBLISH_DENIED' }); + } + if (!resolveProjectDir) return res.status(500).json({ error: 'PROJECT_DIR_UNAVAILABLE' }); + try { + const plan = await buildSharePlan(await resolveProjectDir(projectId), filePath, projectStore?.get?.(projectId)?.metadata); + return res.json(plan.summary); + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + return res.status(code === 'ENOENT' ? 404 : 400).json({ error: code === 'ENOENT' ? 'FILE_NOT_FOUND' : 'FILE_UNAVAILABLE' }); + } + }); + app.post(/^\/api\/projects\/([^/]+)\/files\/(.+)\/publish-public$/u, publicFileMutationHandler(deps.publicFileMutations, async (req, res) => { const startedAt = Date.now(); const requestId = clientRequestIdFor(req); @@ -1298,6 +1331,97 @@ export function registerCollabSyncRoutes( const publisher = deps.sharePublishing; if (!publisher) return res.status(503).json({ error: 'PUBLIC_SHARE_PUBLISH_UNAVAILABLE' }); const scope = publicFilePublicationScope(projectId, filePath, principal); + // A stopped link cannot be reopened by the ordinary publish/update POST: + // that path advances the alias and transfers fresh bytes. Only an explicit + // request may reopen the immutable generation previously bound by Vela. + const requestBody: unknown = req.body; + if (requestBody != null && (typeof requestBody !== 'object' || Array.isArray(requestBody) + || Object.keys(requestBody).some(key => key !== 'mode'))) { + return res.status(400).json({ error: 'INVALID_SHARE_PUBLISH_REQUEST' }); + } + const mode = (requestBody as SharePublishRequest | undefined)?.mode; + if (mode !== undefined && mode !== 'resume') return res.status(400).json({ error: 'INVALID_SHARE_PUBLISH_REQUEST' }); + if (mode === 'resume') { + const remoteState = await deps.readProjectShareState?.(scope).catch(() => null); + if (!remoteState || remoteState.projectId !== projectId) { + return res.status(503).json({ error: 'SHARE_STATE_UNAVAILABLE' }); + } + const stopped = remoteState.publications.find(item => item.sourceFilePath === filePath && item.status === 'stopped'); + const localRevision = publicFilePublicationStore.getRevision(scope); + if (!stopped || (localRevision && localRevision.slug !== stopped.slug)) { + return res.status(409).json({ error: 'SHARE_RESUME_NOT_STOPPED' }); + } + const stopQueue = (publicFilePublicationStore as Partial).listStops?.(); + if (!stopQueue) return res.status(503).json({ error: 'PUBLIC_SHARE_STOP_STATE_UNAVAILABLE' }); + if (stopQueue.some(item => item.resourceTeamId === scope.resourceTeamId + && item.ownerMemberId === scope.ownerMemberId && item.projectId === projectId + && item.filePath === filePath && item.slug === stopped.slug)) { + return res.status(409).json({ error: 'SHARE_STOP_IN_PROGRESS' }); + } + try { + if (publisher.reservations.reserve(scope).slug !== stopped.slug) { + return res.status(409).json({ error: 'SHARE_ALIAS_IDENTITY_MISMATCH' }); + } + const prepared = await publisher.prepare(scope, stopped.slug); + // The remote stopped witness outranks a matching local revision left + // by a crash after Vela stopped but before OD committed the deletion. + // CAS prevents a concurrent new publication from being discarded. + if (localRevision && !publicFilePublicationStore.deleteIfRevisionMatches(scope, localRevision)) { + return res.status(409).json({ error: 'SHARE_RESUME_NOT_STOPPED' }); + } + let receipt: Awaited>; + try { + receipt = await resumeExistingVelaShare(scope, stopped.slug, prepared.run); + } catch { + // A lost/invalid command reply may follow a successful remote alias + // activation. Revoke the exact Owner-verified alias before reporting + // a routine retryable failure; otherwise require explicit revocation. + try { + await stopVelaShare(projectId, stopped.slug, prepared.run); + return res.status(502).json({ error: 'PUBLIC_SHARE_RESUME_UNAVAILABLE' }); + } catch { + const after = await deps.readProjectShareState?.(scope).catch(() => null); + if (after?.projectId === projectId && after.publications.some(item => item.sourceFilePath === filePath + && item.slug === stopped.slug && item.status === 'stopped')) { + return res.status(502).json({ error: 'PUBLIC_SHARE_RESUME_UNAVAILABLE' }); + } + return res.status(502).json({ error: { + code: 'PUBLIC_FILE_MANUAL_REVOKE_REQUIRED', + message: 'The original link may have resumed without a confirmed receipt. Stop the link explicitly using od project share stop before retrying.', + data: { projectId, slug: stopped.slug, fileName: filePath, url: prepared.url }, + } }); + } + } + const publication: PublicFilePublication = { url: prepared.url, slug: stopped.slug, fileName: filePath }; + let outcome: ReturnType; + try { + // Entry path is Vela's saved generation, never a plan from current + // local bytes. This also queues existing local comments atomically. + outcome = publisher.complete({ scope, resourceId: publicFileResourceIdFor(scope), publication, + mapping: [{ sourcePath: filePath, publishedPath: receipt.entryPath }], + result: { status: 'published', receipt }, reopened: true }); + } catch { + // Local commit failed AFTER the old link was made public. Restore the + // stopped state before reporting failure; otherwise a later resume + // would be rejected while the old link remains anonymously live. + try { + await stopVelaShare(projectId, stopped.slug, prepared.run); + return res.status(502).json({ error: 'PUBLIC_SHARE_RESUME_RECORD_UNAVAILABLE' }); + } catch { + // If compensation also fails, the normal owner DELETE can revoke + // via the remote project/file/slug witness even without a local row. + return res.status(502).json({ error: { + code: 'PUBLIC_FILE_MANUAL_REVOKE_REQUIRED', + message: 'The original link was resumed remotely but local metadata could not be saved; stop the link explicitly using od project share stop.', + data: { projectId, ...publication, receipt }, + } }); + } + } + return res.json(sharePublishResponse(outcome, prepared.url)); + } catch { + return res.status(502).json({ error: 'PUBLIC_SHARE_RESUME_UNAVAILABLE' }); + } + } let prepared: Awaited>; try { const reservation = publisher.reservations.reserve(scope); @@ -1489,8 +1613,22 @@ export function registerCollabSyncRoutes( } const scope = publicFilePublicationScope(projectId, filePath, principal); const revision = publicFilePublicationStore.getRevision(scope); - if (!revision || revision.slug !== slug) return res.status(404).json({ error: 'PUBLIC_SHARE_NOT_FOUND' }); + if (revision && revision.slug !== slug) return res.status(404).json({ error: 'PUBLIC_SHARE_NOT_FOUND' }); if (!deps.sharePublishing) return res.status(503).json({ error: 'PUBLIC_SHARE_STOP_UNAVAILABLE' }); + if (!revision) { + if (!sharedProject || sharedProject.ownerMemberId !== principal.memberId) { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_PUBLISH_DENIED' }); + } + // A resumed alias may be active in Vela even if the OD transaction that + // recorded it failed. Permit only the verified Owner to revoke the exact + // project/file/slug that Vela itself reports as active. Never use this + // exception to stop a different locally witnessed publication. + const remoteState = await deps.readProjectShareState?.(scope).catch(() => null); + if (!remoteState || remoteState.projectId !== projectId) return res.status(503).json({ error: 'SHARE_STATE_UNAVAILABLE' }); + if (!remoteState.publications.some(item => item.sourceFilePath === filePath && item.slug === slug && item.status === 'active')) { + return res.status(404).json({ error: 'PUBLIC_SHARE_NOT_FOUND' }); + } + } try { const prepared = await deps.sharePublishing.prepare(scope, slug); const stopped: unknown = JSON.parse(await prepared.run([ @@ -1586,8 +1724,8 @@ export function registerCollabSyncRoutes( const local = publicFilePublicationStore.get(scope); const localUrl = local?.url ?? (local && deps.resolvePublicShareLink?.(projectId, local.slug)) ?? null; const response: import('@open-design/contracts').ProjectFilePublicShareResponse = { - publication: local && localUrl !== null && local.slug === remote?.slug ? { ...local, url: localUrl } : null, - ...(local && localUrl === null && local.slug === remote?.slug ? { link: { status: 'unavailable' as const, code: 'PUBLIC_SHARE_WEB_URL_UNAVAILABLE' as const } } : {}), + publication: remote?.status === 'active' && local && localUrl !== null && local.slug === remote.slug ? { ...local, url: localUrl } : null, + ...(remote?.status === 'active' && local && localUrl === null && local.slug === remote.slug ? { link: { status: 'unavailable' as const, code: 'PUBLIC_SHARE_WEB_URL_UNAVAILABLE' as const } } : {}), status: remote?.status ?? 'none', freshness, }; diff --git a/apps/daemon/src/routes/project/comments.ts b/apps/daemon/src/routes/project/comments.ts index 9d37320160..e32fb0c2f7 100644 --- a/apps/daemon/src/routes/project/comments.ts +++ b/apps/daemon/src/routes/project/comments.ts @@ -139,6 +139,8 @@ export interface RegisterProjectCommentRoutesDeps extends RouteDeps<'db' | 'proj comment: PreviewComment, context: WorkspaceCollabContext | null, ) => boolean | void; + /** Persist exact changes to a previously published personal comment, even while its alias is stopped. */ + onPublishedCommentMutation?: (comment: PreviewComment, context: WorkspaceCollabContext | null, deleted: boolean) => boolean | void; /** Production relay eligibility, including creator-scoped public shares. */ isCommentRelayEligible?: ( projectId: string, @@ -771,6 +773,9 @@ export function registerProjectCommentRoutes(app: Express, ctx: RegisterProjectC req.body?.status, ); if (!saved) return null; + // SAFETY: updatePreviewCommentStatus returns the normalized DB comment; its fields are the PreviewComment wire snapshot consumed by the relay. + const mutationSnapshot = saved as unknown as PreviewComment; + if (ctx.onPublishedCommentMutation && ctx.onPublishedCommentMutation(mutationSnapshot, workspaceContext, false) === false) throw new Error('failed to persist published comment mutation'); updateProject(db, req.params.id, {}); if (syncEnabled) { // SAFETY: the DB normalizer returns the PreviewComment fields consumed by the relay. @@ -929,6 +934,7 @@ export function registerProjectCommentRoutes(app: Express, ctx: RegisterProjectC req.params.commentId, ); if (!deleted) return false; + if (ctx.onPublishedCommentMutation && ctx.onPublishedCommentMutation(existing, workspaceContext, true) === false) throw new Error('failed to persist published comment mutation'); updateProject(db, req.params.id, {}); if (syncEnabled) { requireRelayEnqueued(ctx.onCommentDeleted?.(existing, workspaceContext)); diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 7d34120cfb..ea8ad957aa 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -965,6 +965,7 @@ import { registerTeamResourceShareRoutes } from './routes/team-resource-share.js import { createCollabRuntime } from './collab/runtime.js'; import { createPublicFileStopStartup, createSqlitePublicFilePublicationStore } from './collab/public-file-publication-store.js'; import { sourcePathForCurrentPublication } from './collab/comment-relay-publication-mapping.js'; +import { recordPersonalPublishedCommentMutation } from './collab/published-comment-mutation.js'; import { createPublicFilePublicationRecorder } from './collab/public-file-publication-recording.js'; import { enqueuePublishedFileComments } from './collab/published-file-comment-backfill.js'; import { createVelaPublicFileStop } from './collab/vela-public-file-stop.js'; @@ -3151,6 +3152,12 @@ export interface StartServerResult { routeInventory: import('./route-registration-guard.js').RouteRegistration[]; } +/** Production callback shared by the registered comment routes and HTTP regression tests. */ +export function createPersonalPublishedCommentMutationHandler(db: Database.Database) { + return (comment: PreviewComment, context: WorkspaceCollabContext | null, deleted: boolean) => + recordPersonalPublishedCommentMutation(db, comment, context, deleted); +} + export async function startServer({ port = 7456, host = normalizeDaemonBindHost(process.env.OD_BIND_HOST), @@ -5212,7 +5219,16 @@ export async function startServer({ && item.workspaceMemberId === scope.workspaceMemberId && item.memberStatus === 'active' && item.lifecycleState !== 'deleted' && item.lifecycleState !== 'deleting'); - }, { readAlign: scope => commentAlignment.read(scope) }), + }, { readAlign: scope => commentAlignment.read(scope), + readProjectShareState: createVelaProjectShareState({ + dataRoot: RUNTIME_DATA_DIR, configuredEnv: configuredAmrEnv, registerPersonalProject: false, + }), + readCredential: () => { + const current = readVelaControlApiContext(process.env, configuredAmrEnv()); + return current?.apiUrl && current.controlKey + ? { apiUrl: current.apiUrl, controlKey: current.controlKey } : null; + }, + }), }); const resolveFreshProjectCommentWorkspaceContext = async ( req: any, @@ -9003,6 +9019,7 @@ export async function startServer({ }), ); }, + onPublishedCommentMutation: createPersonalPublishedCommentMutationHandler(db), ...(collabCloud ? { // Explicit UI/CLI request runs even when no project SSE subscriber diff --git a/apps/daemon/tests/collab-sync-routes.test.ts b/apps/daemon/tests/collab-sync-routes.test.ts index 7ecd0afe81..ea8f2306e0 100644 --- a/apps/daemon/tests/collab-sync-routes.test.ts +++ b/apps/daemon/tests/collab-sync-routes.test.ts @@ -27,6 +27,7 @@ import { buildWorkspacePermissions, buildWorkspaceSeatSummary, type WorkspaceCollabContext, + SHARE_MAX_TOTAL_BYTES, } from '@open-design/contracts'; import { runVelaResourceCommand } from '../src/collab/vela-cli-resource-adapter.js'; import { @@ -1906,6 +1907,126 @@ describe('collab sync routes', () => { expect(read).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'p1', resourceTeamId: 'ws-personal-1', ownerMemberId: 'wm-personal-1' })); }); + it('returns missing references from an authorized non-mutating share preflight', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'od-share-plan-')); + tempDirs.push(dir); + await writeFile(path.join(dir, 'index.html'), ''); + const db = new Database(':memory:'); + try { + const share = await publicShareFixture({ db }); + const api = await startSyncServer(personalContextProvider(), { + resolveProjectDir: () => dir, + ...share, + }); + const result = await api.json('/api/projects/p1/files/index.html/share-plan', { method: 'POST' }); + expect(result.status).toBe(200); + expect(result.body.exclusions).toContainEqual({ path: 'assets/missing.png', reason: 'missing' }); + expect(vi.mocked(runVelaResourceCommand)).not.toHaveBeenCalled(); + const denied = await api.json('/api/projects/p1/files/index.html/share-plan', { method: 'POST', headers: { 'x-od-workspace-id': 'wrong' } }); + expect(denied.status).not.toBe(200); + } finally { db.close(); } + }); + + it.each(['permission', 'nonowner', 'placeholder'] as const)('preflight does not plan or mutate for %s denial', async denial => { + const db = new Database(':memory:'); + try { + const share = await publicShareFixture({ db }); + const context = denial === 'permission' ? fixedShareContextProvider(false) : personalContextProvider(); + const resolveProjectDir = vi.fn(() => '/must-not-be-read'); + const resolveSharedProject = vi.fn(async () => ({ + projectId: 'p1', ownerMemberId: denial === 'nonowner' ? 'another-member' : 'wm-personal-1', + sharedAt: new Date(1).toISOString(), name: 'Project', + })); + const register = vi.fn(); + const projectStore = { + has: vi.fn(() => true), register, + get: vi.fn(() => denial === 'placeholder' ? { metadata: { sharedProjectPlaceholderAt: 123 } } : { metadata: {} }), + }; + const api = await startSyncServer(context, { + ...share, resolveProjectDir, resolveSharedProject, + ...(denial === 'placeholder' ? { projectStore } : {}), + }); + const result = await api.json('/api/projects/p1/files/index.html/share-plan', { method: 'POST' }); + expect(result.status).toBe(403); + expect(resolveProjectDir).not.toHaveBeenCalled(); + expect(vi.mocked(runVelaResourceCommand)).not.toHaveBeenCalled(); + expect(share.publicFilePublicationStore.get({ projectId: 'p1', filePath: 'index.html', resourceTeamId: 'ws-personal-1', ownerMemberId: 'wm-personal-1' })).toBeNull(); + expect(share.readProjectShareState).not.toHaveBeenCalled(); + expect(register).not.toHaveBeenCalled(); + } finally { db.close(); } + }); + + it('S15 rejects generated multi-file share over 20 MiB before upstream upload or publication', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'od-share-s15-')); + tempDirs.push(dir); + await writeFile(path.join(dir, 'index.html'), ''); + const asset = path.join(dir, 'large.png'); + await writeFile(asset, Buffer.alloc(1, 65)); + const db = new Database(':memory:'); + try { + const share = await publicShareFixture({ db }); + vi.mocked(readVelaControlApiContext).mockReturnValue({ profile: 'test', apiUrl: 'https://api.example.test', controlKey: 'synthetic', user: null, configMtimeMs: null }); + const api = await startSyncServer(personalContextProvider(), { + resolveProjectDir: () => dir, + resolveSharedProject: async () => null, + ...share, + }); + const planUrl = '/api/projects/p1/files/index.html/share-plan'; + const publishUrl = '/api/projects/p1/files/index.html/publish-public'; + const baseline = await api.json(planUrl, { method: 'POST' }); + expect(baseline.status).toBe(200); + expect(baseline.body.fileCount).toBe(2); + const atLimitAssetBytes = SHARE_MAX_TOTAL_BYTES - (baseline.body.totalBytes - 1); + expect(atLimitAssetBytes).toBeGreaterThan(0); + await writeFile(asset, Buffer.alloc(atLimitAssetBytes, 65)); + const atLimit = await api.json(planUrl, { method: 'POST' }); + expect(atLimit.status).toBe(200); + expect(atLimit.body).toMatchObject({ fileCount: 2, totalBytes: SHARE_MAX_TOTAL_BYTES, exceedsSizeLimit: false }); + await writeFile(asset, Buffer.alloc(atLimitAssetBytes + 1, 65)); + const over = await api.json(planUrl, { method: 'POST' }); + expect(over.status).toBe(200); + expect(over.body).toMatchObject({ fileCount: 2, totalBytes: SHARE_MAX_TOTAL_BYTES + 1, exceedsSizeLimit: true }); + const rejected = await api.json(publishUrl, { method: 'POST' }); + expect(rejected.status).toBe(413); + expect(rejected.body).toMatchObject({ error: 'too_large', bytes: SHARE_MAX_TOTAL_BYTES + 1, totalBytes: SHARE_MAX_TOTAL_BYTES + 1, limit: SHARE_MAX_TOTAL_BYTES, plan: over.body }); + expect(vi.mocked(runVelaResourceCommand)).not.toHaveBeenCalled(); + expect(share.publicFilePublicationStore.get({ projectId: 'p1', filePath: 'index.html', resourceTeamId: 'ws-personal-1', ownerMemberId: 'wm-personal-1' })).toBeNull(); + expect(share.readProjectShareState).not.toHaveBeenCalled(); + await writeFile(asset, Buffer.alloc(atLimitAssetBytes, 65)); + const recovered = await api.json(planUrl, { method: 'POST' }); + expect(recovered.body).toMatchObject({ totalBytes: SHARE_MAX_TOTAL_BYTES, exceedsSizeLimit: false }); + expect(vi.mocked(runVelaResourceCommand)).not.toHaveBeenCalled(); + } finally { db.close(); } + }); + + it('rejects direct public publish of a JSX module before pushing or recording publication', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'od-share-react-module-')); + tempDirs.push(dir); + await writeFile(path.join(dir, 'icons.jsx'), 'export const Icon = () => null;'); + await writeFile(path.join(dir, 'backups.html'), ''); + const db = new Database(':memory:'); + try { + const share = await publicShareFixture({ db }); + const store = share.publicFilePublicationStore; + vi.mocked(readVelaControlApiContext).mockReturnValue({ profile: 'test', apiUrl: 'https://api.example.test', controlKey: 'synthetic', user: null, configMtimeMs: null }); + vi.mocked(runVelaResourceCommand).mockResolvedValue(JSON.stringify({ id: 'v1', version: 1 })); + const api = await startSyncServer(personalContextProvider(), { + resolveProjectDir: () => dir, resolveSharedProject: async () => null, + ...share, + }); + + const preflight = await api.json('/api/projects/p1/files/icons.jsx/share-plan', { method: 'POST' }); + expect(preflight.status).toBe(400); + expect(vi.mocked(runVelaResourceCommand)).not.toHaveBeenCalled(); + expect(store.get({ projectId: 'p1', filePath: 'icons.jsx', resourceTeamId: 'ws-personal-1', ownerMemberId: 'wm-personal-1' })).toBeNull(); + const response = await api.json('/api/projects/p1/files/icons.jsx/publish-public', { method: 'POST' }); + expect(response.status).toBe(400); + expect(response.body.error).toBe('FILE_UNAVAILABLE'); + expect(vi.mocked(runVelaResourceCommand)).not.toHaveBeenCalled(); + expect(store.get({ projectId: 'p1', filePath: 'icons.jsx', resourceTeamId: 'ws-personal-1', ownerMemberId: 'wm-personal-1' })).toBeNull(); + } finally { db.close(); } + }); + it('25 real HTTP freshness compares the rewritten package including dependency bytes', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'od-share-freshness-')); tempDirs.push(dir); diff --git a/apps/daemon/tests/comment-relay-outbox.test.ts b/apps/daemon/tests/comment-relay-outbox.test.ts index 5d696398ca..2e3cfb36a1 100644 --- a/apps/daemon/tests/comment-relay-outbox.test.ts +++ b/apps/daemon/tests/comment-relay-outbox.test.ts @@ -1,185 +1,277 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it, vi } from "vitest"; +import Database from "better-sqlite3"; const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })); -vi.mock('node:child_process', () => ({ execFile: execFileMock })); -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; +vi.mock("node:child_process", () => ({ execFile: execFileMock })); +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { - buildWorkspacePermissions, - buildWorkspaceSeatSummary, - type CollabMemberRole, - type PreviewComment, - type WorkspaceCollabContext, -} from '@open-design/contracts'; + buildWorkspacePermissions, + buildWorkspaceSeatSummary, + type CollabMemberRole, + type PreviewComment, + type WorkspaceCollabContext, +} from "@open-design/contracts"; import { - closeDatabase, - insertConversation, - insertProject, - openDatabase, -} from '../src/db.js'; -import { createCollabCloudService, previewCommentToCloud } from '../src/collab/collab-cloud-service.js'; -import { commentRelayScope } from '../src/collab/comment-relay-scope.js'; + closeDatabase, + insertConversation, + insertProject, + openDatabase, +} from "../src/db.js"; import { - createInMemoryPublicFilePublicationStore, - createSqlitePublicFilePublicationStore, - migratePublicFilePublications, -} from '../src/collab/public-file-publication-store.js'; + createCollabCloudService, + previewCommentToCloud, +} from "../src/collab/collab-cloud-service.js"; +import { commentRelayScope } from "../src/collab/comment-relay-scope.js"; import { - commentRelayLocalBindingMatches, - createCommentRelayOutboxStore, - migrateCommentRelayOutbox, - type CommentRelayLocalProjectBinding, -} from '../src/collab/comment-relay-outbox.js'; -import { CollabCloudError, type CollabCloudClient } from '../src/integrations/collab-cloud.js'; -import { createVelaCliCollabClient } from '../src/collab/vela-cli-collab-client.js'; + createInMemoryPublicFilePublicationStore, + createSqlitePublicFilePublicationStore, + migratePublicFilePublications, +} from "../src/collab/public-file-publication-store.js"; +import { + commentRelayLocalBindingMatches, + createCommentRelayOutboxStore, + migrateCommentRelayOutbox, + type CommentRelayLocalProjectBinding, +} from "../src/collab/comment-relay-outbox.js"; +import { + CollabCloudError, + type CollabCloudClient, +} from "../src/integrations/collab-cloud.js"; +import { createVelaCliCollabClient } from "../src/collab/vela-cli-collab-client.js"; let tempDir: string | null = null; afterEach(() => { - vi.useRealTimers(); - closeDatabase(); - execFileMock.mockReset(); - vi.unstubAllEnvs(); - if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); - tempDir = null; + vi.useRealTimers(); + closeDatabase(); + execFileMock.mockReset(); + vi.unstubAllEnvs(); + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; }); function seededDb() { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-comment-relay-outbox-')); - const db = openDatabase(tempDir); - insertProject(db, { id: 'p1', name: 'Project', createdAt: 1, updatedAt: 1 }); - insertConversation(db, { - id: 'conv-local', - projectId: 'p1', - title: 'Chat', - createdAt: 1, - updatedAt: 1, - }); - return db; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "od-comment-relay-outbox-")); + const db = openDatabase(tempDir); + insertProject(db, { id: "p1", name: "Project", createdAt: 1, updatedAt: 1 }); + insertConversation(db, { + id: "conv-local", + projectId: "p1", + title: "Chat", + createdAt: 1, + updatedAt: 1, + }); + return db; } function context( - role: CollabMemberRole = 'member', - patch: Partial = {}, + role: CollabMemberRole = "member", + patch: Partial = {}, ): WorkspaceCollabContext { - return { - workspaceId: 'workspace-a', - workspaceType: 'team', - workspaceMemberId: `member-${role}`, - role, - memberStatus: 'active', - lifecycleState: 'active', - billingState: 'active', - planId: null, - providerMode: 'platform_credits', - seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 3 }), - permissions: buildWorkspacePermissions({ role, lifecycleState: 'active' }), - teamId: 'team-a', - displayName: role, - ...patch, - }; + return { + workspaceId: "workspace-a", + workspaceType: "team", + workspaceMemberId: `member-${role}`, + role, + memberStatus: "active", + lifecycleState: "active", + billingState: "active", + planId: null, + providerMode: "platform_credits", + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 3 }), + permissions: buildWorkspacePermissions({ role, lifecycleState: "active" }), + teamId: "team-a", + displayName: role, + ...patch, + }; } function comment(patch: Partial = {}): PreviewComment { - return { - id: 'comment-1', - projectId: 'p1', - conversationId: 'conv-local', - filePath: 'index.html', - elementId: 'hero', - selector: '#hero', - label: 'Hero', - text: 'Hero', - position: { x: 1, y: 2, width: 3, height: 4 }, - htmlHint: '

', - note: 'first note', - status: 'open', - createdAt: 10, - updatedAt: 10, - authorMemberId: 'member-member', - ...patch, - }; + return { + id: "comment-1", + projectId: "p1", + conversationId: "conv-local", + filePath: "index.html", + elementId: "hero", + selector: "#hero", + label: "Hero", + text: "Hero", + position: { x: 1, y: 2, width: 3, height: 4 }, + htmlHint: "

", + note: "first note", + status: "open", + createdAt: 10, + updatedAt: 10, + authorMemberId: "member-member", + ...patch, + }; } function clientWithPush( - push: CollabCloudClient['pushComment'], + push: CollabCloudClient["pushComment"], ): CollabCloudClient { - return { pushComment: push } as unknown as CollabCloudClient; + return { pushComment: push } as unknown as CollabCloudClient; } function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; } async function waitForCondition(predicate: () => boolean): Promise { - for (let attempt = 0; attempt < 20; attempt += 1) { - if (predicate()) return; - await Promise.resolve(); - } - throw new Error('condition did not become true'); + for (let attempt = 0; attempt < 20; attempt += 1) { + if (predicate()) return; + await Promise.resolve(); + } + throw new Error("condition did not become true"); } -describe('durable Team comment relay outbox', () => { - it('starts with an empty durable outbox without pushing a comment', async () => { - vi.useFakeTimers(); - const db = seededDb(); - const pushes = vi.fn(async () => ({ seq: 1 })); - const service = createCollabCloudService({ - client: clientWithPush(pushes), - commentOutbox: createCommentRelayOutboxStore(db, () => 0), - listProjectIds: () => [], - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - now: () => 0, - }); +describe("durable Team comment relay outbox", () => { + it("K4 reuses a durable event key across lost ACK, restart, and same-slug resume but rotates it for a changed mutation or alias", () => { + const db = seededDb(); + const first = createCommentRelayOutboxStore(db, () => 100); + const input = { + workspaceId: "workspace-a", + workspaceMemberId: "owner-a", + teamId: "workspace-a", + relayScope: "personal" as const, + projectId: "p1", + expectedOwnerMemberId: "owner-a", + comment: previewCommentToCloud( + comment({ authorMemberId: "owner-a" }), + "owner-a", + ), + publication: { + slug: "same-alias", + token: "rev-1", + publicFilePath: "index.html", + }, + }; + first.enqueue(input); + const original = first.listDue(100)[0]!; + expect(original.eventKey).toMatch(/^[a-f0-9-]{36}$/); + first.defer(original, { + nextAttemptAt: 100, + error: "remote committed, ACK lost", + }); + expect(first.listDue(100)[0]?.eventKey).toBe(original.eventKey); + expect(first.acknowledge(first.listDue(100)[0]!, "delivered")).toBe(true); + closeDatabase(); + const reopened = openDatabase(tempDir!); + const second = createCommentRelayOutboxStore(reopened, () => 100); + second.enqueue({ + ...input, + publication: { ...input.publication, token: "rev-2" }, + }); + expect(second.listDue(100)[0]?.eventKey).toBe(original.eventKey); + second.enqueue({ + ...input, + comment: previewCommentToCloud( + comment({ + authorMemberId: "owner-a", + status: "resolved", + updatedAt: 11, + }), + "owner-a", + ), + publication: { ...input.publication, token: "rev-2" }, + }); + const changed = second.listDue(100)[0]!; + expect(changed.eventKey).not.toBe(original.eventKey); + second.enqueue({ + ...input, + comment: previewCommentToCloud( + comment({ + authorMemberId: "owner-a", + status: "resolved", + updatedAt: 11, + }), + "owner-a", + ), + publication: { ...input.publication, token: "rev-3" }, + }); + expect(second.listDue(100)[0]?.eventKey).toBe(changed.eventKey); + second.enqueue({ + ...input, + comment: previewCommentToCloud( + comment({ + authorMemberId: "owner-a", + status: "resolved", + updatedAt: 11, + }), + "owner-a", + ), + publication: { + slug: "other-alias", + token: "rev-4", + publicFilePath: "index.html", + }, + }); + expect(second.listDue(100)[0]?.eventKey).not.toBe(changed.eventKey); + }); + it("starts with an empty durable outbox without pushing a comment", async () => { + vi.useFakeTimers(); + const db = seededDb(); + const pushes = vi.fn(async () => ({ seq: 1 })); + const service = createCollabCloudService({ + client: clientWithPush(pushes), + commentOutbox: createCommentRelayOutboxStore(db, () => 0), + listProjectIds: () => [], + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 0, + }); - service.start(); - await vi.advanceTimersByTimeAsync(0); + service.start(); + await vi.advanceTimersByTimeAsync(0); - expect(pushes).not.toHaveBeenCalled(); - service.dispose(); - }); + expect(pushes).not.toHaveBeenCalled(); + service.dispose(); + }); - it('keeps a materialized team member relay-eligible when the local mirror has no creator', () => { - const teamMember = context('member', { - workspaceId: 'ws-multi-client', - teamId: 'ws-multi-client', - workspaceMemberId: 'mem-multi-viewer', - }); + it("keeps a materialized team member relay-eligible when the local mirror has no creator", () => { + const teamMember = context("member", { + workspaceId: "ws-multi-client", + teamId: "ws-multi-client", + workspaceMemberId: "mem-multi-viewer", + }); - expect(commentRelayScope({ - binding: { - workspaceId: 'ws-multi-client', - visibility: 'team', - resourceState: 'active', - createdByWorkspaceMemberId: null, - }, - context: teamMember, - projectId: 'project-1', - filePath: 'index.html', - publications: createInMemoryPublicFilePublicationStore(), - })).toMatchObject({ - workspaceId: 'ws-multi-client', - teamId: 'ws-multi-client', - ownerMemberId: 'mem-multi-viewer', - relayScope: 'team', - }); - }); + expect( + commentRelayScope({ + binding: { + workspaceId: "ws-multi-client", + visibility: "team", + resourceState: "active", + createdByWorkspaceMemberId: null, + }, + context: teamMember, + projectId: "project-1", + filePath: "index.html", + publications: createInMemoryPublicFilePublicationStore(), + }), + ).toMatchObject({ + workspaceId: "ws-multi-client", + teamId: "ws-multi-client", + ownerMemberId: "mem-multi-viewer", + relayScope: "team", + }); + }); - it('backfills legacy personal file paths idempotently without touching malformed or unrelated rows', () => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-comment-relay-legacy-')); - const dbPath = path.join(tempDir, 'app.sqlite'); - let db = new Database(dbPath); - db.exec(` + it("backfills legacy personal file paths idempotently without touching malformed or unrelated rows", () => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "od-comment-relay-legacy-"), + ); + const dbPath = path.join(tempDir, "app.sqlite"); + let db = new Database(dbPath); + db.exec(` CREATE TABLE comment_relay_outbox ( workspace_id TEXT NOT NULL, workspace_member_id TEXT NOT NULL, team_id TEXT NOT NULL, relay_scope TEXT NOT NULL DEFAULT 'team', @@ -191,1236 +283,1807 @@ describe('durable Team comment relay outbox', () => { PRIMARY KEY (workspace_id, workspace_member_id, project_id, comment_id) ); `); - const insert = db.prepare(` + const insert = db.prepare(` INSERT INTO comment_relay_outbox (workspace_id, workspace_member_id, team_id, relay_scope, project_id, comment_id, expected_owner_member_id, payload_json, next_attempt_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 100, 100, 100) `); - const payload = (id: string, filePath: string) => JSON.stringify( - previewCommentToCloud(comment({ id, filePath }), 'owner-a'), - ); - insert.run('workspace-a', 'owner-a', 'workspace-a', 'personal', 'p1', 'exact', 'owner-a', payload('exact', 'index.html')); - insert.run('workspace-a', 'owner-a', 'workspace-a', 'personal', 'p1', 'other-file', 'owner-a', payload('other-file', 'other.html')); - insert.run('workspace-a', 'owner-b', 'workspace-a', 'personal', 'p1', 'other-principal', 'owner-b', payload('other-principal', 'index.html')); - insert.run('workspace-a', 'owner-a', 'workspace-a', 'team', 'p1', 'team-row', 'owner-a', payload('team-row', 'index.html')); - insert.run('workspace-a', 'owner-a', 'workspace-a', 'personal', 'p1', 'malformed', 'owner-a', '{not json'); - - migrateCommentRelayOutbox(db); - migratePublicFilePublications(db); - expect(db.prepare(`SELECT file_path FROM comment_relay_outbox WHERE comment_id = 'exact'`).get()).toEqual({ file_path: 'index.html' }); - expect(db.prepare(`SELECT file_path FROM comment_relay_outbox WHERE comment_id = 'malformed'`).get()).toEqual({ file_path: '' }); - migrateCommentRelayOutbox(db); - db.close(); - db = new Database(dbPath); - migrateCommentRelayOutbox(db); - migratePublicFilePublications(db); - - const publications = createSqlitePublicFilePublicationStore(db, () => 100); - const exactScope = { resourceTeamId: 'workspace-a', ownerMemberId: 'owner-a', projectId: 'p1', filePath: 'index.html' }; - publications.set(exactScope, { url: 'https://example.test/index', slug: 'index', fileName: 'index.html' }); - expect(publications.deleteIfRevisionMatches(exactScope, publications.getRevision(exactScope)!)).toBe(true); - expect(db.prepare(`SELECT comment_id, file_path FROM comment_relay_outbox ORDER BY comment_id`).all()).toEqual([ - { comment_id: 'malformed', file_path: '' }, - { comment_id: 'other-file', file_path: 'other.html' }, - { comment_id: 'other-principal', file_path: 'index.html' }, - { comment_id: 'team-row', file_path: '' }, - ]); - db.close(); - }); - - it('rolls back publication deletion when personal outbox cancellation is aborted', () => { - const db = seededDb(); - const scope = { resourceTeamId: 'workspace-a', ownerMemberId: 'owner-a', projectId: 'p1', filePath: 'index.html' }; - const publications = createSqlitePublicFilePublicationStore(db, () => 100); - publications.set(scope, { url: 'https://example.test/index', slug: 'index', fileName: 'index.html' }); - const outbox = createCommentRelayOutboxStore(db, () => 100); - outbox.enqueue({ workspaceId: 'workspace-a', workspaceMemberId: 'owner-a', teamId: 'workspace-a', relayScope: 'personal', projectId: 'p1', expectedOwnerMemberId: 'owner-a', comment: previewCommentToCloud(comment({ id: 'atomic-row' }), 'owner-a') }); - db.exec(`CREATE TRIGGER abort_personal_outbox_delete BEFORE DELETE ON comment_relay_outbox BEGIN SELECT RAISE(ABORT, 'forced cancellation failure'); END;`); - - const revision = publications.getRevision(scope)!; - expect(() => publications.deleteIfRevisionMatches(scope, revision)).toThrow('forced cancellation failure'); - expect(publications.get(scope)?.slug).toBe('index'); - expect(outbox.listDue(100).map((record) => record.commentId)).toEqual(['atomic-row']); - db.exec('DROP TRIGGER abort_personal_outbox_delete'); - expect(publications.deleteIfRevisionMatches(scope, revision)).toBe(true); - expect(publications.get(scope)).toBeNull(); - expect(outbox.count()).toBe(0); - }); - - it('delivers an active personal owner publication through a durable outbox after restart', async () => { - const db = seededDb(); - const owner = context('owner', { - workspaceId: 'workspace-personal', - workspaceType: 'personal', - workspaceMemberId: 'personal-owner', - teamId: '', - }); - const binding: CommentRelayLocalProjectBinding = { - workspaceId: owner.workspaceId, - visibility: 'personal', - resourceState: 'active', - createdByWorkspaceMemberId: owner.workspaceMemberId, - }; - const publications = createSqlitePublicFilePublicationStore(db, () => 100); - publications.set({ - resourceTeamId: owner.workspaceId, - ownerMemberId: owner.workspaceMemberId, - projectId: 'p1', - filePath: 'index.html', - }, { url: 'https://example.test/share', slug: 'public-slug', fileName: 'index.html' }); - const scope = (projectId: string, filePath: string, current: WorkspaceCollabContext) => - commentRelayScope({ binding, context: current, projectId, filePath, publications }); - const firstOutbox = createCommentRelayOutboxStore(db, () => 100); - const first = createCollabCloudService({ - client: clientWithPush(async () => { throw new Error('offline'); }), - commentOutbox: firstOutbox, - commentRelayScope: scope, - resolveLocalProjectRelayBinding: () => ({ workspaceId: owner.workspaceId, ownerMemberId: owner.workspaceMemberId }), - validateCommentRelayProjectBinding: (record) => commentRelayLocalBindingMatches(record, binding), - resolveCommentRelayWorkspaceContext: async () => owner, - listRemoteProjectRelayBindings: async () => [], - listProjectIds: () => [], resolveLocalConversationId: () => 'conv-local', mergeComment: () => 'unchanged', - now: () => 100, retryDelayMs: () => 0, - }); - expect(first.enqueueComment(comment({ authorMemberId: owner.workspaceMemberId }), owner)).toBe(true); - await first.flushPendingComments(); - expect(firstOutbox.count()).toBe(1); - first.dispose(); - - // A real restart closes the SQLite handle. Reopen both the durable outbox - // and publication witness rather than merely constructing another service. - closeDatabase(); - const reopened = openDatabase(tempDir!); - const restartedPublications = createSqlitePublicFilePublicationStore(reopened, () => 100); - const restartedScope = (projectId: string, filePath: string, current: WorkspaceCollabContext) => - commentRelayScope({ binding, context: current, projectId, filePath, publications: restartedPublications }); - const pushed: Array<{ teamId: string; projectId: string }> = []; - const restartedOutbox = createCommentRelayOutboxStore(reopened, () => 100); - const restarted = createCollabCloudService({ - client: clientWithPush(async (teamId, projectId) => { pushed.push({ teamId, projectId }); return { seq: 9 }; }), - commentOutbox: restartedOutbox, - commentRelayScope: restartedScope, - resolveLocalProjectRelayBinding: () => ({ workspaceId: owner.workspaceId, ownerMemberId: owner.workspaceMemberId }), - validateCommentRelayProjectBinding: (record) => commentRelayLocalBindingMatches(record, binding), - resolveCommentRelayWorkspaceContext: async () => owner, - listRemoteProjectRelayBindings: async () => [], - listProjectIds: () => [], resolveLocalConversationId: () => 'conv-local', mergeComment: () => 'unchanged', - now: () => 100, retryDelayMs: () => 0, - }); - await restarted.flushPendingComments(); - expect(pushed).toEqual([{ teamId: owner.workspaceId, projectId: 'p1' }]); - expect(restartedOutbox.count()).toBe(0); - restarted.dispose(); - }); - it('fails closed for no publication, a different file, stopped publication, and a switched personal identity', async () => { - const db = seededDb(); - const owner = context('owner', { workspaceId: 'workspace-personal', workspaceType: 'personal', workspaceMemberId: 'personal-owner', teamId: '' }); - const binding: CommentRelayLocalProjectBinding = { workspaceId: owner.workspaceId, visibility: 'personal', resourceState: 'active', createdByWorkspaceMemberId: owner.workspaceMemberId }; - const publications = createSqlitePublicFilePublicationStore(db); - const scope = (projectId: string, filePath: string, current: WorkspaceCollabContext) => - commentRelayScope({ binding, context: current, projectId, filePath, publications }); - expect(scope('p1', 'index.html', owner)).toBeNull(); - publications.set({ resourceTeamId: owner.workspaceId, ownerMemberId: owner.workspaceMemberId, projectId: 'p1', filePath: 'index.html' }, { url: 'https://example.test/share', slug: 'public-slug', fileName: 'index.html' }); - expect(scope('p1', 'other.html', owner)).toBeNull(); - - const outbox = createCommentRelayOutboxStore(db, () => 100); - let freshIdentity = owner; - let pushes = 0; - const service = createCollabCloudService({ - client: clientWithPush(async () => { pushes += 1; return { seq: 1 }; }), - commentOutbox: outbox, commentRelayScope: scope, - resolveLocalProjectRelayBinding: () => ({ workspaceId: owner.workspaceId, ownerMemberId: owner.workspaceMemberId }), - validateCommentRelayProjectBinding: (record) => commentRelayLocalBindingMatches(record, binding), - resolveCommentRelayWorkspaceContext: async () => freshIdentity, - listRemoteProjectRelayBindings: async () => [], - listProjectIds: () => [], resolveLocalConversationId: () => 'conv-local', mergeComment: () => 'unchanged', - now: () => 100, retryDelayMs: () => 0, - }); - expect(service.enqueueComment(comment({ authorMemberId: owner.workspaceMemberId }), owner)).toBe(true); - // Same-principal/different-file, project, and principal controls must stay. - outbox.enqueue({ workspaceId: owner.workspaceId, workspaceMemberId: owner.workspaceMemberId, teamId: owner.workspaceId, relayScope: 'personal', projectId: 'p1', expectedOwnerMemberId: owner.workspaceMemberId, comment: previewCommentToCloud(comment({ id: 'other-file', filePath: 'other.html' }), owner.workspaceMemberId) }); - outbox.enqueue({ workspaceId: owner.workspaceId, workspaceMemberId: owner.workspaceMemberId, teamId: owner.workspaceId, relayScope: 'personal', projectId: 'p2', expectedOwnerMemberId: owner.workspaceMemberId, comment: previewCommentToCloud(comment({ id: 'other-project', projectId: 'p2' }), owner.workspaceMemberId) }); - outbox.enqueue({ workspaceId: owner.workspaceId, workspaceMemberId: 'other-owner', teamId: owner.workspaceId, relayScope: 'personal', projectId: 'p1', expectedOwnerMemberId: 'other-owner', comment: previewCommentToCloud(comment({ id: 'other-principal' }), 'other-owner') }); - outbox.enqueue({ workspaceId: owner.workspaceId, workspaceMemberId: owner.workspaceMemberId, teamId: owner.workspaceId, relayScope: 'team', projectId: 'p1', expectedOwnerMemberId: owner.workspaceMemberId, comment: previewCommentToCloud(comment({ id: 'team-row' }), owner.workspaceMemberId) }); - // A stop invalidates pre-stop rows even when the stable alias is immediately - // republished; an active witness cannot safely distinguish their generation. - const publicationScope = { resourceTeamId: owner.workspaceId, ownerMemberId: owner.workspaceMemberId, projectId: 'p1', filePath: 'index.html' }; - expect(publications.deleteIfRevisionMatches(publicationScope, publications.getRevision(publicationScope)!)).toBe(true); - publications.set({ resourceTeamId: owner.workspaceId, ownerMemberId: owner.workspaceMemberId, projectId: 'p1', filePath: 'index.html' }, { url: 'https://example.test/share', slug: 'public-slug', fileName: 'index.html' }); - expect(outbox.listDue(100).map((record) => record.commentId).sort()).toEqual(['other-file', 'other-principal', 'other-project', 'team-row']); - - service.dispose(); - // Cancellation and the unrelated controls survive restart independently. - closeDatabase(); - const reopened = openDatabase(tempDir!); - const restartedPublications = createSqlitePublicFilePublicationStore(reopened, () => 100); - const restartedScope = (projectId: string, filePath: string, current: WorkspaceCollabContext) => - commentRelayScope({ binding, context: current, projectId, filePath, publications: restartedPublications }); - const restartedOutbox = createCommentRelayOutboxStore(reopened, () => 100); - const controls = restartedOutbox.listDue(100); - expect(controls.map((record) => record.commentId).sort()).toEqual(['other-file', 'other-principal', 'other-project', 'team-row']); - for (const record of controls) restartedOutbox.acknowledge(record); - const restarted = createCollabCloudService({ - client: clientWithPush(async () => { pushes += 1; return { seq: 1 }; }), - commentOutbox: restartedOutbox, commentRelayScope: restartedScope, - resolveLocalProjectRelayBinding: () => ({ workspaceId: owner.workspaceId, ownerMemberId: owner.workspaceMemberId }), - validateCommentRelayProjectBinding: (record) => commentRelayLocalBindingMatches(record, binding), - resolveCommentRelayWorkspaceContext: async () => owner, - listRemoteProjectRelayBindings: async () => [], - listProjectIds: () => [], resolveLocalConversationId: () => 'conv-local', mergeComment: () => 'unchanged', - now: () => 100, retryDelayMs: () => 0, - }); - await restarted.flushPendingComments(); - expect(pushes).toBe(0); - expect(restartedOutbox.count()).toBe(0); - // A new post-resume revision still has the normal delivery path. - expect(restarted.enqueueComment(comment({ id: 'after-resume', note: 'after-resume', authorMemberId: owner.workspaceMemberId }), owner)).toBe(true); - await restarted.flushPendingComments(); - expect(pushes).toBe(1); - expect(restartedOutbox.count()).toBe(0); - restarted.dispose(); - }); - - it.each([ - ['active file first', ['index.html', 'stopped.html']], - ['stopped file first', ['stopped.html', 'index.html']], - ] as const)('validates every personal publication in a mixed identity batch (%s)', async (_order, filePaths) => { - const db = seededDb(); - const owner = context('owner', { - workspaceId: 'workspace-personal', - workspaceType: 'personal', - workspaceMemberId: 'personal-owner', - teamId: '', - }); - const binding: CommentRelayLocalProjectBinding = { - workspaceId: owner.workspaceId, - visibility: 'personal', - resourceState: 'active', - createdByWorkspaceMemberId: owner.workspaceMemberId, - }; - const publications = createSqlitePublicFilePublicationStore(db, () => 100); - for (const filePath of filePaths) { - publications.set({ - resourceTeamId: owner.workspaceId, - ownerMemberId: owner.workspaceMemberId, - projectId: 'p1', - filePath, - }, { url: `https://example.test/${filePath}`, slug: filePath, fileName: filePath }); - } - const scope = (projectId: string, filePath: string, current: WorkspaceCollabContext) => - commentRelayScope({ binding, context: current, projectId, filePath, publications }); - const pushed: string[] = []; - const outbox = createCommentRelayOutboxStore(db, () => 100); - const service = createCollabCloudService({ - client: clientWithPush(async (_teamId, _projectId, payload) => { - pushed.push(payload.filePath); - return { seq: pushed.length }; - }), - commentOutbox: outbox, - commentRelayScope: scope, - resolveLocalProjectRelayBinding: () => ({ workspaceId: owner.workspaceId, ownerMemberId: owner.workspaceMemberId }), - validateCommentRelayProjectBinding: (record) => commentRelayLocalBindingMatches(record, binding), - resolveCommentRelayWorkspaceContext: async () => owner, - listRemoteProjectRelayBindings: async () => [], - listProjectIds: () => [], resolveLocalConversationId: () => 'conv-local', mergeComment: () => 'unchanged', - now: () => 100, retryDelayMs: () => 0, - }); - for (const [index, filePath] of filePaths.entries()) { - expect(service.enqueueComment(comment({ id: `comment-${index}`, filePath, authorMemberId: owner.workspaceMemberId }), owner)).toBe(true); - } - // This is deliberately after enqueue: the records share one durable owner - // identity batch, but only the active publication may cross the relay. - publications.delete({ - resourceTeamId: owner.workspaceId, - ownerMemberId: owner.workspaceMemberId, - projectId: 'p1', - filePath: 'stopped.html', - }); - - await service.flushPendingComments(); - - expect(pushed).toEqual(['index.html']); - expect(outbox.count()).toBe(0); - service.dispose(); - }); - - it('does not let a stale different-creator project block a valid personal project in the same identity batch', async () => { - const db = seededDb(); - const owner = context('owner', { workspaceId: 'workspace-personal', workspaceType: 'personal', workspaceMemberId: 'personal-owner', teamId: '' }); - const otherCreator = 'another-creator'; - let freshIdentity = owner; - const bindings = new Map([ - ['p1', { workspaceId: owner.workspaceId, visibility: 'personal', resourceState: 'active', createdByWorkspaceMemberId: owner.workspaceMemberId }], - ['p2', { workspaceId: owner.workspaceId, visibility: 'personal', resourceState: 'active', createdByWorkspaceMemberId: otherCreator }], - ]); - const publications = createSqlitePublicFilePublicationStore(db, () => 100); - for (const [projectId, creator] of [['p1', owner.workspaceMemberId], ['p2', otherCreator]] as const) { - publications.set({ resourceTeamId: owner.workspaceId, ownerMemberId: creator, projectId, filePath: 'index.html' }, { url: `https://example.test/${projectId}`, slug: projectId, fileName: 'index.html' }); - } - const scope = (projectId: string, filePath: string, current: WorkspaceCollabContext) => - commentRelayScope({ binding: bindings.get(projectId), context: current, projectId, filePath, publications }); - const pushed: string[] = []; - const outbox = createCommentRelayOutboxStore(db, () => 100); - const service = createCollabCloudService({ - client: clientWithPush(async (_teamId, projectId) => { pushed.push(projectId); return { seq: pushed.length }; }), - commentOutbox: outbox, commentRelayScope: scope, - resolveLocalProjectRelayBinding: (projectId) => { - const binding = bindings.get(projectId); - return binding ? { workspaceId: binding.workspaceId!, ownerMemberId: binding.createdByWorkspaceMemberId! } : null; - }, - validateCommentRelayProjectBinding: (record) => commentRelayLocalBindingMatches(record, bindings.get(record.projectId)), - resolveCommentRelayWorkspaceContext: async () => freshIdentity, - listRemoteProjectRelayBindings: async () => [], listProjectIds: () => [], resolveLocalConversationId: () => 'conv-local', mergeComment: () => 'unchanged', - now: () => 100, retryDelayMs: () => 0, - }); - expect(service.enqueueComment(comment({ id: 'valid-p1', projectId: 'p1', authorMemberId: owner.workspaceMemberId }), owner)).toBe(true); - // Simulate a row queued before p2's creator changed: it retains the same - // durable principal batch, but its current creator scope is no longer ours. - outbox.enqueue({ workspaceId: owner.workspaceId, workspaceMemberId: owner.workspaceMemberId, teamId: owner.workspaceId, relayScope: 'personal', projectId: 'p2', expectedOwnerMemberId: otherCreator, comment: previewCommentToCloud(comment({ id: 'stale-p2', projectId: 'p2' }), owner.workspaceMemberId) }); - - await service.flushPendingComments(); - expect(pushed).toEqual(['p1']); - expect(outbox.count()).toBe(0); - - // A later login/principal transition is not proof of an unpublish; it - // leaves a newly queued active-file row deferred for retry. - expect(service.enqueueComment(comment({ id: 'deferred-p1', projectId: 'p1', authorMemberId: owner.workspaceMemberId }), owner)).toBe(true); - freshIdentity = { ...owner, workspaceMemberId: 'switched-account' }; - await service.flushPendingComments(); - expect(pushed).toEqual(['p1']); - expect(outbox.count()).toBe(1); - service.dispose(); - }); - - it.each(['owner', 'admin', 'member'])( - 'delivers %s comments under the exact queued Workspace identity', - async (role) => { - const db = seededDb(); - const queuedContext = context(role); - const calls: Array<{ teamId: string; memberId: string }> = []; - const outbox = createCommentRelayOutboxStore(db, () => 100); - const service = createCollabCloudService({ - client: clientWithPush(async (teamId, _projectId, payload) => { - calls.push({ teamId, memberId: payload.memberId }); - return { seq: 7 }; - }), - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - resolveRemoteProjectOwnerMemberId: async () => 'project-owner', - listProjectIds: () => [], - resolveProjectWorkspaceContext: async (_projectId, options) => { - expect(options).toEqual({ fresh: true }); - return queuedContext; - }, - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - now: () => 100, - retryDelayMs: () => 0, - }); - - expect(service.enqueueComment(comment({ authorMemberId: queuedContext.workspaceMemberId }), queuedContext)) - .toBe(true); - expect(outbox.count()).toBe(1); - await service.flushPendingComments(); - - expect(calls).toEqual([ - { teamId: 'team-a', memberId: queuedContext.workspaceMemberId }, - ]); - expect(outbox.count()).toBe(0); - service.dispose(); - }, - ); - - it('retries a failed push after restart and confirms the cloud sequence', async () => { - const db = seededDb(); - const queuedContext = context('member'); - const firstOutbox = createCommentRelayOutboxStore(db, () => 200); - const firstService = createCollabCloudService({ - client: clientWithPush(async () => { - throw new Error('Vela TLS unavailable'); - }), - commentOutbox: firstOutbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - resolveRemoteProjectOwnerMemberId: async () => 'project-owner', - listProjectIds: () => [], - resolveProjectWorkspaceContext: async () => queuedContext, - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - now: () => 200, - retryDelayMs: () => 0, - }); - firstService.enqueueComment(comment(), queuedContext); - await firstService.flushPendingComments(); - expect(firstOutbox.count()).toBe(1); - firstService.dispose(); - - // A new service + newly opened SQLite handle sees and drains the same row. - closeDatabase(); - const reopened = openDatabase(tempDir!); - const reopenedOutbox = createCommentRelayOutboxStore(reopened, () => 200); - const confirmed: Array<{ commentId: string; seq: number; authorKey: string | undefined; memberId: string }> = []; - const secondService = createCollabCloudService({ - client: clientWithPush(async () => ({ seq: 42, authorKey: 'a'.repeat(64) })), - commentOutbox: reopenedOutbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - resolveRemoteProjectOwnerMemberId: async () => 'project-owner', - listProjectIds: () => [], - resolveProjectWorkspaceContext: async () => queuedContext, - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - onCommentPushed: ({ commentId, seq, authorKey, memberId }) => confirmed.push({ commentId, seq, authorKey, memberId }), - now: () => 200, - retryDelayMs: () => 0, - }); - await secondService.flushPendingComments(); - - expect(reopenedOutbox.count()).toBe(0); - expect(confirmed).toEqual([{ commentId: 'comment-1', seq: 42, authorKey: 'a'.repeat(64), memberId: queuedContext.workspaceMemberId }]); - secondService.dispose(); - }); - - it('retries in the running daemon after the relay recovers', async () => { - const db = seededDb(); - const queuedContext = context('owner'); - let attempts = 0; - const outbox = createCommentRelayOutboxStore(db, () => 250); - const service = createCollabCloudService({ - client: clientWithPush(async () => { - attempts += 1; - if (attempts === 1) throw new Error('temporary network failure'); - return { seq: 9 }; - }), - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - resolveRemoteProjectOwnerMemberId: async () => 'project-owner', - listProjectIds: () => [], - resolveProjectWorkspaceContext: async () => queuedContext, - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - now: () => 250, - retryDelayMs: () => 0, - }); - service.enqueueComment(comment({ authorMemberId: 'member-owner' }), queuedContext); - - await service.flushPendingComments(); - expect(attempts).toBe(1); - expect(outbox.count()).toBe(1); - await service.flushPendingComments(); - - expect(attempts).toBe(2); - expect(outbox.count()).toBe(0); - service.dispose(); - }); - - it('coalesces edits and delete tombstones without losing their latest state', async () => { - const db = seededDb(); - const queuedContext = context('admin'); - const pushed: Array<{ note: string; deleted: boolean }> = []; - const outbox = createCommentRelayOutboxStore(db, () => 300); - const service = createCollabCloudService({ - client: clientWithPush(async (_teamId, _projectId, payload) => { - pushed.push({ note: payload.note, deleted: payload.deleted === true }); - return { seq: pushed.length }; - }), - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - resolveRemoteProjectOwnerMemberId: async () => 'project-owner', - listProjectIds: () => [], - resolveProjectWorkspaceContext: async () => queuedContext, - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - now: () => 300, - retryDelayMs: () => 0, - }); - - service.enqueueComment(comment(), queuedContext); - service.enqueueComment(comment({ note: 'edited note', updatedAt: 20 }), queuedContext); - expect(outbox.count()).toBe(1); - await service.flushPendingComments(); - expect(pushed).toEqual([{ note: 'edited note', deleted: false }]); - - service.enqueueCommentDeletion(comment({ note: 'edited note', updatedAt: 20 }), queuedContext); - await service.flushPendingComments(); - expect(pushed).toEqual([ - { note: 'edited note', deleted: false }, - { note: 'edited note', deleted: true }, - ]); - expect(outbox.count()).toBe(0); - service.dispose(); - }); - - it('keeps a row pending across identity, Workspace, and Personal mismatches', async () => { - const db = seededDb(); - const queuedContext = context('member'); - let resolved = context('member', { workspaceMemberId: 'other-member' }); - let pushes = 0; - const outbox = createCommentRelayOutboxStore(db, () => 400); - const service = createCollabCloudService({ - client: clientWithPush(async () => { - pushes += 1; - return { seq: 1 }; - }), - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - resolveRemoteProjectOwnerMemberId: async () => 'project-owner', - listProjectIds: () => [], - resolveProjectWorkspaceContext: async () => resolved, - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - now: () => 400, - retryDelayMs: () => 0, - }); - service.enqueueComment(comment(), queuedContext); - - await service.flushPendingComments(); - resolved = context('member', { - workspaceId: 'workspace-b', - teamId: 'team-b', - }); - await service.flushPendingComments(); - resolved = context('member', { - workspaceType: 'personal', - workspaceId: 'workspace-personal', - }); - delete (resolved as Partial).teamId; - await service.flushPendingComments(); - - expect(pushes).toBe(0); - expect(outbox.count()).toBe(1); - - resolved = queuedContext; - await service.flushPendingComments(); - expect(pushes).toBe(1); - expect(outbox.count()).toBe(0); - service.dispose(); - }); - - it('keeps the delivery pending when the remote catalog is unavailable', async () => { - const db = seededDb(); - const queuedContext = context('member'); - let pushes = 0; - const outbox = createCommentRelayOutboxStore(db, () => 500); - const service = createCollabCloudService({ - client: clientWithPush(async () => { - pushes += 1; - return { seq: 1 }; - }), - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - resolveRemoteProjectOwnerMemberId: async () => { - throw new Error('catalog unavailable'); - }, - listProjectIds: () => [], - resolveProjectWorkspaceContext: async () => queuedContext, - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - now: () => 500, - retryDelayMs: () => 0, - }); - service.enqueueComment(comment(), queuedContext); - await service.flushPendingComments(); - - expect(pushes).toBe(0); - expect(outbox.count()).toBe(1); - service.dispose(); - }); - - it.each([ - { name: 'remote unshare', remoteOwner: null }, - { name: 'remote owner conflict', remoteOwner: 'different-owner' }, - ])('terminally cancels after $name without pushing', async ({ remoteOwner }) => { - const db = seededDb(); - const queuedContext = context('admin'); - let pushes = 0; - const outbox = createCommentRelayOutboxStore(db, () => 600); - const service = createCollabCloudService({ - client: clientWithPush(async () => { - pushes += 1; - return { seq: 1 }; - }), - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - resolveRemoteProjectOwnerMemberId: async () => remoteOwner, - listProjectIds: () => [], - resolveProjectWorkspaceContext: async () => queuedContext, - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - now: () => 600, - retryDelayMs: () => 0, - }); - service.enqueueComment(comment(), queuedContext); - await service.flushPendingComments(); - - expect(pushes).toBe(0); - expect(outbox.count()).toBe(0); - service.dispose(); - }); - - it('reports a missing remote owner while conditionally acknowledging only that revision', async () => { - const db = seededDb(); - const queuedContext = context('member'); - const outbox = createCommentRelayOutboxStore(db, () => 700); - const catalog = deferred>(); - const pushed: string[] = []; - const confirmed: string[] = []; - const errors: unknown[] = []; - const service = createCollabCloudService({ - client: clientWithPush(async (_teamId, projectId) => { - pushed.push(projectId); - return { seq: pushed.length }; - }), - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - listProjectIds: () => [], - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - resolveCommentRelayWorkspaceContext: async () => queuedContext, - listRemoteProjectRelayBindings: async () => catalog.promise, - onCommentPushed: ({ projectId }) => confirmed.push(projectId), - onError: (error) => errors.push(error), - now: () => 700, - retryDelayMs: () => 0, - }); - expect(service.enqueueComment(comment({ id: 'missing-owner', note: 'old revision' }), queuedContext)).toBe(true); - expect(service.enqueueComment(comment({ id: 'valid-project', projectId: 'p2' }), queuedContext)).toBe(true); - - const flushing = service.flushPendingComments(); - await Promise.resolve(); - expect(service.enqueueComment(comment({ id: 'missing-owner', note: 'new revision', updatedAt: 20 }), queuedContext)).toBe(true); - catalog.resolve([{ projectId: 'p2', ownerMemberId: 'project-owner' }]); - await flushing; - - expect(pushed).toEqual(['p2']); - expect(confirmed).toEqual(['p2']); - expect(errors).toHaveLength(1); - expect(errors[0]).toBeInstanceOf(Error); - expect((errors[0] as Error).message).toContain('remote project owner missing'); - expect(outbox.count()).toBe(1); - expect(outbox.listDue(700)[0]?.comment.note).toBe('new revision'); - service.dispose(); - }); - - it('reuses one fresh authority and one catalog snapshot for an exact identity batch', async () => { - const db = seededDb(); - const queuedContext = context('member'); - let timestamp = 700; - let authorityReads = 0; - let catalogReads = 0; - let legacyAuthorityReads = 0; - let legacyCatalogReads = 0; - const pushed: string[] = []; - const outbox = createCommentRelayOutboxStore(db, () => timestamp++); - const deps = Object.assign({ - client: clientWithPush(async (_teamId, projectId, payload) => { - pushed.push(`${projectId}:${payload.id}`); - return { seq: pushed.length }; - }), - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - resolveProjectWorkspaceContext: async () => { - legacyAuthorityReads += 1; - return queuedContext; - }, - resolveRemoteProjectOwnerMemberId: async () => { - legacyCatalogReads += 1; - return 'project-owner'; - }, - listProjectIds: () => [], - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - now: () => 1_000, - retryDelayMs: () => 0, - }, { - resolveCommentRelayWorkspaceContext: async () => { - authorityReads += 1; - return queuedContext; - }, - listRemoteProjectRelayBindings: async () => { - catalogReads += 1; - return [ - { projectId: 'p1', ownerMemberId: 'project-owner' }, - { projectId: 'p2', ownerMemberId: 'project-owner' }, - ]; - }, - }); - const service = createCollabCloudService(deps); - - for (const [projectId, commentId] of [ - ['p1', 'comment-1'], - ['p1', 'comment-2'], - ['p2', 'comment-3'], - ['p2', 'comment-4'], - ] as const) { - expect(service.enqueueComment(comment({ id: commentId, projectId }), queuedContext)) - .toBe(true); - } - - await service.flushPendingComments(); - - expect(authorityReads).toBe(1); - expect(catalogReads).toBe(1); - expect(legacyAuthorityReads).toBe(0); - expect(legacyCatalogReads).toBe(0); - expect(pushed).toHaveLength(4); - expect(outbox.count()).toBe(0); - service.dispose(); - }); - - it('bounds independent project pushes while preserving each project order', async () => { - const db = seededDb(); - const queuedContext = context('member'); - let timestamp = 800; - let activePushes = 0; - let maxActivePushes = 0; - const started: string[] = []; - const deliveries = new Map( - [ - 'p1:comment-1', - 'p1:comment-2', - 'p2:comment-3', - 'p3:comment-4', - 'p4:comment-5', - 'p5:comment-6', - ].map((key) => [key, deferred<{ seq: number }>()]), - ); - const outbox = createCommentRelayOutboxStore(db, () => timestamp++); - const deps = Object.assign({ - client: clientWithPush(async (_teamId, projectId, payload) => { - const key = `${projectId}:${payload.id}`; - started.push(key); - activePushes += 1; - maxActivePushes = Math.max(maxActivePushes, activePushes); - try { - return await deliveries.get(key)!.promise; - } finally { - activePushes -= 1; - } - }), - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - resolveProjectWorkspaceContext: async () => queuedContext, - resolveRemoteProjectOwnerMemberId: async () => 'project-owner', - listProjectIds: () => [], - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - now: () => 1_000, - retryDelayMs: () => 0, - }, { - resolveCommentRelayWorkspaceContext: async () => queuedContext, - listRemoteProjectRelayBindings: async () => - ['p1', 'p2', 'p3', 'p4', 'p5'].map((projectId) => ({ - projectId, - ownerMemberId: 'project-owner', - })), - }); - const service = createCollabCloudService(deps); - for (const [projectId, commentId] of [ - ['p1', 'comment-1'], - ['p1', 'comment-2'], - ['p2', 'comment-3'], - ['p3', 'comment-4'], - ['p4', 'comment-5'], - ['p5', 'comment-6'], - ] as const) { - expect(service.enqueueComment(comment({ id: commentId, projectId }), queuedContext)) - .toBe(true); - } - - const flushing = service.flushPendingComments(); - try { - await waitForCondition(() => started.length >= 4); - expect(started).toEqual([ - 'p1:comment-1', - 'p2:comment-3', - 'p3:comment-4', - 'p4:comment-5', - ]); - expect(started).not.toContain('p1:comment-2'); - expect(maxActivePushes).toBe(4); - - deliveries.get('p2:comment-3')!.resolve({ seq: 3 }); - await waitForCondition(() => started.includes('p5:comment-6')); - expect(started).not.toContain('p1:comment-2'); - - deliveries.get('p1:comment-1')!.resolve({ seq: 1 }); - await waitForCondition(() => started.includes('p1:comment-2')); - expect(maxActivePushes).toBe(4); - } finally { - let seq = 10; - for (const delivery of deliveries.values()) { - delivery.resolve({ seq }); - seq += 1; - } - await flushing; - service.dispose(); - } - - expect(outbox.count()).toBe(0); - }); - - it('keeps a newer revision queued when a batched push acknowledges an older payload', async () => { - const db = seededDb(); - const queuedContext = context('member'); - let timestamp = 900; - const firstPush = deferred<{ seq: number }>(); - const pushedNotes: string[] = []; - const outbox = createCommentRelayOutboxStore(db, () => timestamp++); - const deps = Object.assign({ - client: clientWithPush(async (_teamId, _projectId, payload) => { - pushedNotes.push(payload.note); - if (pushedNotes.length === 1) return firstPush.promise; - return { seq: 2 }; - }), - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - listProjectIds: () => [], - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - now: () => 1_000, - retryDelayMs: () => 0, - }, { - resolveCommentRelayWorkspaceContext: async () => queuedContext, - listRemoteProjectRelayBindings: async () => [{ - projectId: 'p1', - ownerMemberId: 'project-owner', - }], - }); - const service = createCollabCloudService(deps); - service.enqueueComment(comment(), queuedContext); - - const firstFlush = service.flushPendingComments(); - await waitForCondition(() => pushedNotes.length === 1); - service.enqueueComment(comment({ note: 'newer note', updatedAt: 20 }), queuedContext); - firstPush.resolve({ seq: 1 }); - await firstFlush; - - expect(outbox.count()).toBe(1); - await service.flushPendingComments(); - expect(pushedNotes).toEqual(['first note', 'newer note']); - expect(outbox.count()).toBe(0); - service.dispose(); - }); - - it('does not lose a drain request when a newer same-project revision arrives during push', async () => { - const db = seededDb(); - const queuedContext = context('member'); - let timestamp = 950; - const firstPush = deferred<{ seq: number }>(); - const pushedNotes: string[] = []; - const outbox = createCommentRelayOutboxStore(db, () => timestamp++); - const deps = Object.assign({ - client: clientWithPush(async (_teamId, _projectId, payload) => { - pushedNotes.push(payload.note); - if (pushedNotes.length === 1) return firstPush.promise; - return { seq: 2 }; - }), - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - listProjectIds: () => [], - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - now: () => 1_000, - retryDelayMs: () => 0, - }, { - resolveCommentRelayWorkspaceContext: async () => queuedContext, - listRemoteProjectRelayBindings: async () => [{ - projectId: 'p1', - ownerMemberId: 'project-owner', - }], - }); - const service = createCollabCloudService(deps); - service.enqueueComment(comment(), queuedContext); - - const firstFlush = service.flushPendingComments(); - await waitForCondition(() => pushedNotes.length === 1); - service.enqueueComment(comment({ note: 'newer note', updatedAt: 20 }), queuedContext); - // This is the same signal queuedCloudComment schedules in production. - // It must join the active drain instead of becoming a dropped no-op. - await service.flushPendingComments(); - firstPush.resolve({ seq: 1 }); - await firstFlush; - - expect(pushedNotes).toEqual(['first note', 'newer note']); - expect(outbox.count()).toBe(0); - service.dispose(); - }); - - it('fails a batch closed before catalog or push when its fresh identity changed', async () => { - const db = seededDb(); - const queuedContext = context('member'); - let catalogReads = 0; - let pushes = 0; - const outbox = createCommentRelayOutboxStore(db, () => 1_000); - const deps = Object.assign({ - client: clientWithPush(async () => { - pushes += 1; - return { seq: 1 }; - }), - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - listProjectIds: () => [], - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - now: () => 1_000, - retryDelayMs: () => 0, - }, { - resolveCommentRelayWorkspaceContext: async () => - context('member', { workspaceMemberId: 'different-member' }), - listRemoteProjectRelayBindings: async () => { - catalogReads += 1; - return [{ projectId: 'p1', ownerMemberId: 'project-owner' }]; - }, - }); - const service = createCollabCloudService(deps); - service.enqueueComment(comment(), queuedContext); - - await service.flushPendingComments(); - - expect(catalogReads).toBe(0); - expect(pushes).toBe(0); - expect(outbox.count()).toBe(1); - service.dispose(); - }); - - it('does not let a stalled outbound batch block inbound comment pulls', async () => { - const db = seededDb(); - const queuedContext = context('member'); - const stalledPush = deferred<{ seq: number }>(); - let pushStarted = false; - let pulls = 0; - const outbox = createCommentRelayOutboxStore(db, () => 1_100); - const client = { - pushComment: async () => { - pushStarted = true; - return stalledPush.promise; - }, - registerMember: async () => ({ - memberId: 'member-member', - displayName: 'member', - role: 'member' as const, - }), - pullComments: async () => { - pulls += 1; - return { - comments: [], - latestSeq: 0, - etag: null, - notModified: true, - }; - }, - } as unknown as CollabCloudClient; - const deps = Object.assign({ - client, - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - listProjectIds: () => ['p1'], - resolveProjectWorkspaceContext: async () => queuedContext, - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - now: () => 1_100, - retryDelayMs: () => 0, - }, { - resolveCommentRelayWorkspaceContext: async () => queuedContext, - listRemoteProjectRelayBindings: async () => [{ - projectId: 'p1', - ownerMemberId: 'project-owner', - }], - }); - const service = createCollabCloudService(deps); - service.enqueueComment(comment(), queuedContext); - - const polling = service.pollOnce(); - try { - await waitForCondition(() => pushStarted); - await waitForCondition(() => pulls === 1); - expect(pulls).toBe(1); - } finally { - stalledPush.resolve({ seq: 1 }); - await polling; - service.dispose(); - } - }); - - it.each<{ - name: string; - binding: CommentRelayLocalProjectBinding; - }>([ - { - name: 'local unshare', - binding: { - workspaceId: 'workspace-a', - visibility: 'personal', - resourceState: 'active', - createdByWorkspaceMemberId: 'project-owner', - }, - }, - { - name: 'local deletion', - binding: { - workspaceId: 'workspace-a', - visibility: 'team', - resourceState: 'deleted', - createdByWorkspaceMemberId: 'project-owner', - }, - }, - { - name: 'local owner mismatch', - binding: { - workspaceId: 'workspace-a', - visibility: 'team', - resourceState: 'active', - createdByWorkspaceMemberId: 'different-owner', - }, - }, - ])('does not push after $name even while the remote catalog is stale', async ({ binding }) => { - const db = seededDb(); - const queuedContext = context('member'); - let pushes = 0; - const outbox = createCommentRelayOutboxStore(db, () => 1_200); - const deps = Object.assign({ - client: clientWithPush(async () => { - pushes += 1; - return { seq: 1 }; - }), - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - validateCommentRelayProjectBinding: (record: Parameters< - typeof commentRelayLocalBindingMatches - >[0]) => commentRelayLocalBindingMatches(record, binding), - listProjectIds: () => [], - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - now: () => 1_200, - retryDelayMs: () => 0, - }, { - resolveCommentRelayWorkspaceContext: async () => queuedContext, - listRemoteProjectRelayBindings: async () => [{ - projectId: 'p1', - ownerMemberId: 'project-owner', - }], - }); - const service = createCollabCloudService(deps); - service.enqueueComment(comment(), queuedContext); - - await service.flushPendingComments(); - - expect(pushes).toBe(0); - expect(outbox.count()).toBe(0); - service.dispose(); - }); - - it('handles a detached outbox failure after dispose without another delivery', async () => { - const db = seededDb(); - const queuedContext = context('member'); - const stalledPush = deferred<{ seq: number }>(); - const errors: unknown[] = []; - let pushStarted = false; - let confirmations = 0; - const outbox = createCommentRelayOutboxStore(db, () => 1_300); - const client = { - pushComment: async () => { - pushStarted = true; - return stalledPush.promise; - }, - registerMember: async () => ({ - memberId: 'member-member', - displayName: 'member', - role: 'member' as const, - }), - pullComments: async () => ({ - comments: [], - latestSeq: 0, - etag: null, - notModified: true, - }), - } as unknown as CollabCloudClient; - const deps = Object.assign({ - client, - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ - workspaceId: 'workspace-a', - ownerMemberId: 'project-owner', - }), - listProjectIds: () => ['p1'], - resolveProjectWorkspaceContext: async () => queuedContext, - resolveLocalConversationId: () => 'conv-local', - mergeComment: () => 'unchanged', - onCommentPushed: () => { - confirmations += 1; - }, - onError: (error: unknown) => errors.push(error), - now: () => 1_300, - retryDelayMs: () => 0, - }, { - resolveCommentRelayWorkspaceContext: async () => queuedContext, - listRemoteProjectRelayBindings: async () => [{ - projectId: 'p1', - ownerMemberId: 'project-owner', - }], - }); - const service = createCollabCloudService(deps); - service.enqueueComment(comment(), queuedContext); - - await service.pollOnce(); - await waitForCondition(() => pushStarted); - service.dispose(); - stalledPush.reject(new Error('relay stopped during shutdown')); - await waitForCondition(() => errors.length === 1); - - expect(confirmations).toBe(0); - expect(outbox.count()).toBe(1); - }); - - it('acknowledges only the rejected revision for a structured 410 SHARE_STOPPED and does not resurrect it after SQLite reopen', async () => { - const db = seededDb(); - const queuedContext = context('member'); - const outbox = createCommentRelayOutboxStore(db, () => 1_400); - const stdout = fs.readFileSync( - new URL('./fixtures/vela-cli-comment-push-share-stopped-927e0a62e7.stdout.json', import.meta.url), - 'utf8', - ); - // Exercise the default runner: runVelaCommand captures stdout on its - // rejected process-boundary error, then defaultRunVelaCollab must preserve - // the structured terminal failure through runJson and the SQLite outbox. - vi.stubEnv('VELA_BIN', process.execPath); - vi.stubEnv('OD_DATA_DIR', ''); - execFileMock.mockImplementationOnce((_bin, _args, _options, callback) => { - callback(new Error('unclassified command failure'), stdout, ''); - return { pid: 4321 }; - }); - const client = createVelaCliCollabClient(); - const errors: unknown[] = []; - const confirmed: Array<{ commentId: string; seq: number }> = []; - const service = createCollabCloudService({ - client, - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ workspaceId: 'workspace-a', ownerMemberId: 'project-owner' }), - resolveRemoteProjectOwnerMemberId: async () => 'project-owner', - listProjectIds: () => [], resolveProjectWorkspaceContext: async () => queuedContext, - resolveLocalConversationId: () => 'conv-local', mergeComment: () => 'unchanged', - onCommentPushed: (event) => confirmed.push(event), onError: (error) => errors.push(error), - now: () => 1_400, retryDelayMs: () => 0, - }); - expect(service.enqueueComment(comment(), queuedContext)).toBe(true); - await service.flushPendingComments(); - - expect(outbox.count()).toBe(0); - expect(confirmed).toEqual([]); - expect(errors).toHaveLength(1); - expect(errors[0]).toBeInstanceOf(CollabCloudError); - expect(errors[0]).toMatchObject({ status: 410, code: 'SHARE_STOPPED' }); - service.dispose(); - closeDatabase(); - - const reopened = openDatabase(tempDir!); - const restartedOutbox = createCommentRelayOutboxStore(reopened, () => 1_400); - expect(restartedOutbox.count()).toBe(0); - }); - - it('keeps a newer queued revision when the in-flight older revision is terminally rejected', async () => { - const db = seededDb(); - const queuedContext = context('member'); - const outbox = createCommentRelayOutboxStore(db, () => 1_500); - const inFlight = deferred(); - const service = createCollabCloudService({ - client: clientWithPush(async () => { - await inFlight.promise; - throw new CollabCloudError(410, 'SHARE_STOPPED'); - }), - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ workspaceId: 'workspace-a', ownerMemberId: 'project-owner' }), - resolveRemoteProjectOwnerMemberId: async () => 'project-owner', - listProjectIds: () => [], resolveProjectWorkspaceContext: async () => queuedContext, - resolveLocalConversationId: () => 'conv-local', mergeComment: () => 'unchanged', - now: () => 1_500, retryDelayMs: () => 0, - }); - service.enqueueComment(comment({ note: 'old' }), queuedContext); - const flushing = service.flushPendingComments(); - await Promise.resolve(); - service.enqueueComment(comment({ note: 'new' }), queuedContext); - inFlight.resolve(); - await flushing; - - const [pending] = outbox.listDue(1_500); - expect(pending?.comment.note).toBe('new'); - expect(outbox.count()).toBe(1); - service.dispose(); - }); - - it.each([ - new CollabCloudError(429, 'RATE_LIMITED', 'SHARE_STOPPED'), - new CollabCloudError(500, 'UPSTREAM_FAILURE', 'SHARE_STOPPED'), - new CollabCloudError(410, 'ANOTHER_GONE_CODE', 'SHARE_STOPPED'), - new Error('410 SHARE_STOPPED'), - ])('retries non-terminal structured failures without using message text', async (failure) => { - const db = seededDb(); - const queuedContext = context('member'); - const outbox = createCommentRelayOutboxStore(db, () => 1_600); - let attempts = 0; - const service = createCollabCloudService({ - client: clientWithPush(async () => { attempts += 1; throw failure; }), - commentOutbox: outbox, - resolveLocalProjectRelayBinding: () => ({ workspaceId: 'workspace-a', ownerMemberId: 'project-owner' }), - resolveRemoteProjectOwnerMemberId: async () => 'project-owner', - listProjectIds: () => [], resolveProjectWorkspaceContext: async () => queuedContext, - resolveLocalConversationId: () => 'conv-local', mergeComment: () => 'unchanged', - now: () => 1_600, retryDelayMs: () => 0, - }); - service.enqueueComment(comment(), queuedContext); - await service.flushPendingComments(); - await service.flushPendingComments(); - await service.flushPendingComments(); - expect(attempts).toBe(3); - expect(outbox.count()).toBe(1); - service.dispose(); - }); - + const payload = (id: string, filePath: string) => + JSON.stringify( + previewCommentToCloud(comment({ id, filePath }), "owner-a"), + ); + insert.run( + "workspace-a", + "owner-a", + "workspace-a", + "personal", + "p1", + "exact", + "owner-a", + payload("exact", "index.html"), + ); + insert.run( + "workspace-a", + "owner-a", + "workspace-a", + "personal", + "p1", + "other-file", + "owner-a", + payload("other-file", "other.html"), + ); + insert.run( + "workspace-a", + "owner-b", + "workspace-a", + "personal", + "p1", + "other-principal", + "owner-b", + payload("other-principal", "index.html"), + ); + insert.run( + "workspace-a", + "owner-a", + "workspace-a", + "team", + "p1", + "team-row", + "owner-a", + payload("team-row", "index.html"), + ); + insert.run( + "workspace-a", + "owner-a", + "workspace-a", + "personal", + "p1", + "malformed", + "owner-a", + "{not json", + ); + + migrateCommentRelayOutbox(db); + migratePublicFilePublications(db); + expect( + db + .prepare( + `SELECT file_path FROM comment_relay_outbox WHERE comment_id = 'exact'`, + ) + .get(), + ).toEqual({ file_path: "index.html" }); + expect( + db + .prepare( + `SELECT file_path FROM comment_relay_outbox WHERE comment_id = 'malformed'`, + ) + .get(), + ).toEqual({ file_path: "" }); + migrateCommentRelayOutbox(db); + db.close(); + db = new Database(dbPath); + migrateCommentRelayOutbox(db); + migratePublicFilePublications(db); + + const publications = createSqlitePublicFilePublicationStore(db, () => 100); + const exactScope = { + resourceTeamId: "workspace-a", + ownerMemberId: "owner-a", + projectId: "p1", + filePath: "index.html", + }; + publications.set(exactScope, { + url: "https://example.test/index", + slug: "index", + fileName: "index.html", + }); + expect( + publications.deleteIfRevisionMatches( + exactScope, + publications.getRevision(exactScope)!, + ), + ).toBe(true); + expect( + db + .prepare( + `SELECT comment_id, file_path FROM comment_relay_outbox ORDER BY comment_id`, + ) + .all(), + ).toEqual([ + { comment_id: "malformed", file_path: "" }, + { comment_id: "other-file", file_path: "other.html" }, + { comment_id: "other-principal", file_path: "index.html" }, + { comment_id: "team-row", file_path: "" }, + ]); + db.close(); + }); + + it("rolls back publication deletion when personal outbox cancellation is aborted", () => { + const db = seededDb(); + const scope = { + resourceTeamId: "workspace-a", + ownerMemberId: "owner-a", + projectId: "p1", + filePath: "index.html", + }; + const publications = createSqlitePublicFilePublicationStore(db, () => 100); + publications.set(scope, { + url: "https://example.test/index", + slug: "index", + fileName: "index.html", + }); + const outbox = createCommentRelayOutboxStore(db, () => 100); + outbox.enqueue({ + workspaceId: "workspace-a", + workspaceMemberId: "owner-a", + teamId: "workspace-a", + relayScope: "personal", + projectId: "p1", + expectedOwnerMemberId: "owner-a", + comment: previewCommentToCloud(comment({ id: "atomic-row" }), "owner-a"), + }); + db.exec( + `CREATE TRIGGER abort_personal_outbox_delete BEFORE DELETE ON comment_relay_outbox BEGIN SELECT RAISE(ABORT, 'forced cancellation failure'); END;`, + ); + + const revision = publications.getRevision(scope)!; + expect(() => publications.deleteIfRevisionMatches(scope, revision)).toThrow( + "forced cancellation failure", + ); + expect(publications.get(scope)?.slug).toBe("index"); + expect(outbox.listDue(100).map((record) => record.commentId)).toEqual([ + "atomic-row", + ]); + db.exec("DROP TRIGGER abort_personal_outbox_delete"); + expect(publications.deleteIfRevisionMatches(scope, revision)).toBe(true); + expect(publications.get(scope)).toBeNull(); + expect(outbox.count()).toBe(0); + }); + + it("delivers an active personal owner publication through a durable outbox after restart", async () => { + const db = seededDb(); + const owner = context("owner", { + workspaceId: "workspace-personal", + workspaceType: "personal", + workspaceMemberId: "personal-owner", + teamId: "", + }); + const binding: CommentRelayLocalProjectBinding = { + workspaceId: owner.workspaceId, + visibility: "personal", + resourceState: "active", + createdByWorkspaceMemberId: owner.workspaceMemberId, + }; + const publications = createSqlitePublicFilePublicationStore(db, () => 100); + publications.set( + { + resourceTeamId: owner.workspaceId, + ownerMemberId: owner.workspaceMemberId, + projectId: "p1", + filePath: "index.html", + }, + { + url: "https://example.test/share", + slug: "public-slug", + fileName: "index.html", + }, + ); + const scope = ( + projectId: string, + filePath: string, + current: WorkspaceCollabContext, + ) => + commentRelayScope({ + binding, + context: current, + projectId, + filePath, + publications, + }); + const firstOutbox = createCommentRelayOutboxStore(db, () => 100); + const first = createCollabCloudService({ + client: clientWithPush(async () => { + throw new Error("offline"); + }), + commentOutbox: firstOutbox, + commentRelayScope: scope, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: owner.workspaceId, + ownerMemberId: owner.workspaceMemberId, + }), + validateCommentRelayProjectBinding: (record) => + commentRelayLocalBindingMatches(record, binding), + resolveCommentRelayWorkspaceContext: async () => owner, + listRemoteProjectRelayBindings: async () => [], + listProjectIds: () => [], + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 100, + retryDelayMs: () => 0, + }); + expect( + first.enqueueComment( + comment({ authorMemberId: owner.workspaceMemberId }), + owner, + ), + ).toBe(true); + await first.flushPendingComments(); + expect(firstOutbox.count()).toBe(1); + first.dispose(); + + // A real restart closes the SQLite handle. Reopen both the durable outbox + // and publication witness rather than merely constructing another service. + closeDatabase(); + const reopened = openDatabase(tempDir!); + const restartedPublications = createSqlitePublicFilePublicationStore( + reopened, + () => 100, + ); + const restartedScope = ( + projectId: string, + filePath: string, + current: WorkspaceCollabContext, + ) => + commentRelayScope({ + binding, + context: current, + projectId, + filePath, + publications: restartedPublications, + }); + const pushed: Array<{ teamId: string; projectId: string }> = []; + const restartedOutbox = createCommentRelayOutboxStore(reopened, () => 100); + const restarted = createCollabCloudService({ + client: clientWithPush(async (teamId, projectId) => { + pushed.push({ teamId, projectId }); + return { seq: 9 }; + }), + commentOutbox: restartedOutbox, + commentRelayScope: restartedScope, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: owner.workspaceId, + ownerMemberId: owner.workspaceMemberId, + }), + validateCommentRelayProjectBinding: (record) => + commentRelayLocalBindingMatches(record, binding), + resolveCommentRelayWorkspaceContext: async () => owner, + listRemoteProjectRelayBindings: async () => [], + listProjectIds: () => [], + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 100, + retryDelayMs: () => 0, + }); + await restarted.flushPendingComments(); + expect(pushed).toEqual([{ teamId: owner.workspaceId, projectId: "p1" }]); + expect(restartedOutbox.count()).toBe(0); + restarted.dispose(); + }); + it("fails closed for no publication, a different file, stopped publication, and a switched personal identity", async () => { + const db = seededDb(); + const owner = context("owner", { + workspaceId: "workspace-personal", + workspaceType: "personal", + workspaceMemberId: "personal-owner", + teamId: "", + }); + const binding: CommentRelayLocalProjectBinding = { + workspaceId: owner.workspaceId, + visibility: "personal", + resourceState: "active", + createdByWorkspaceMemberId: owner.workspaceMemberId, + }; + const publications = createSqlitePublicFilePublicationStore(db); + const scope = ( + projectId: string, + filePath: string, + current: WorkspaceCollabContext, + ) => + commentRelayScope({ + binding, + context: current, + projectId, + filePath, + publications, + }); + expect(scope("p1", "index.html", owner)).toBeNull(); + publications.set( + { + resourceTeamId: owner.workspaceId, + ownerMemberId: owner.workspaceMemberId, + projectId: "p1", + filePath: "index.html", + }, + { + url: "https://example.test/share", + slug: "public-slug", + fileName: "index.html", + }, + ); + expect(scope("p1", "other.html", owner)).toBeNull(); + + const outbox = createCommentRelayOutboxStore(db, () => 100); + let freshIdentity = owner; + let pushes = 0; + const service = createCollabCloudService({ + client: clientWithPush(async () => { + pushes += 1; + return { seq: 1 }; + }), + commentOutbox: outbox, + commentRelayScope: scope, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: owner.workspaceId, + ownerMemberId: owner.workspaceMemberId, + }), + validateCommentRelayProjectBinding: (record) => + commentRelayLocalBindingMatches(record, binding), + resolveCommentRelayWorkspaceContext: async () => freshIdentity, + listRemoteProjectRelayBindings: async () => [], + listProjectIds: () => [], + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 100, + retryDelayMs: () => 0, + }); + expect( + service.enqueueComment( + comment({ authorMemberId: owner.workspaceMemberId }), + owner, + ), + ).toBe(true); + // Same-principal/different-file, project, and principal controls must stay. + outbox.enqueue({ + workspaceId: owner.workspaceId, + workspaceMemberId: owner.workspaceMemberId, + teamId: owner.workspaceId, + relayScope: "personal", + projectId: "p1", + expectedOwnerMemberId: owner.workspaceMemberId, + comment: previewCommentToCloud( + comment({ id: "other-file", filePath: "other.html" }), + owner.workspaceMemberId, + ), + }); + outbox.enqueue({ + workspaceId: owner.workspaceId, + workspaceMemberId: owner.workspaceMemberId, + teamId: owner.workspaceId, + relayScope: "personal", + projectId: "p2", + expectedOwnerMemberId: owner.workspaceMemberId, + comment: previewCommentToCloud( + comment({ id: "other-project", projectId: "p2" }), + owner.workspaceMemberId, + ), + }); + outbox.enqueue({ + workspaceId: owner.workspaceId, + workspaceMemberId: "other-owner", + teamId: owner.workspaceId, + relayScope: "personal", + projectId: "p1", + expectedOwnerMemberId: "other-owner", + comment: previewCommentToCloud( + comment({ id: "other-principal" }), + "other-owner", + ), + }); + outbox.enqueue({ + workspaceId: owner.workspaceId, + workspaceMemberId: owner.workspaceMemberId, + teamId: owner.workspaceId, + relayScope: "team", + projectId: "p1", + expectedOwnerMemberId: owner.workspaceMemberId, + comment: previewCommentToCloud( + comment({ id: "team-row" }), + owner.workspaceMemberId, + ), + }); + // A stop invalidates pre-stop rows even when the stable alias is immediately + // republished; an active witness cannot safely distinguish their generation. + const publicationScope = { + resourceTeamId: owner.workspaceId, + ownerMemberId: owner.workspaceMemberId, + projectId: "p1", + filePath: "index.html", + }; + expect( + publications.deleteIfRevisionMatches( + publicationScope, + publications.getRevision(publicationScope)!, + ), + ).toBe(true); + publications.set( + { + resourceTeamId: owner.workspaceId, + ownerMemberId: owner.workspaceMemberId, + projectId: "p1", + filePath: "index.html", + }, + { + url: "https://example.test/share", + slug: "public-slug", + fileName: "index.html", + }, + ); + expect( + outbox + .listDue(100) + .map((record) => record.commentId) + .sort(), + ).toEqual(["other-file", "other-principal", "other-project", "team-row"]); + + service.dispose(); + // Cancellation and the unrelated controls survive restart independently. + closeDatabase(); + const reopened = openDatabase(tempDir!); + const restartedPublications = createSqlitePublicFilePublicationStore( + reopened, + () => 100, + ); + const restartedScope = ( + projectId: string, + filePath: string, + current: WorkspaceCollabContext, + ) => + commentRelayScope({ + binding, + context: current, + projectId, + filePath, + publications: restartedPublications, + }); + const restartedOutbox = createCommentRelayOutboxStore(reopened, () => 100); + const controls = restartedOutbox.listDue(100); + expect(controls.map((record) => record.commentId).sort()).toEqual([ + "other-file", + "other-principal", + "other-project", + "team-row", + ]); + for (const record of controls) restartedOutbox.acknowledge(record); + const restarted = createCollabCloudService({ + client: clientWithPush(async () => { + pushes += 1; + return { seq: 1 }; + }), + commentOutbox: restartedOutbox, + commentRelayScope: restartedScope, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: owner.workspaceId, + ownerMemberId: owner.workspaceMemberId, + }), + validateCommentRelayProjectBinding: (record) => + commentRelayLocalBindingMatches(record, binding), + resolveCommentRelayWorkspaceContext: async () => owner, + listRemoteProjectRelayBindings: async () => [], + listProjectIds: () => [], + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 100, + retryDelayMs: () => 0, + }); + await restarted.flushPendingComments(); + expect(pushes).toBe(0); + expect(restartedOutbox.count()).toBe(0); + // A new post-resume revision still has the normal delivery path. + expect( + restarted.enqueueComment( + comment({ + id: "after-resume", + note: "after-resume", + authorMemberId: owner.workspaceMemberId, + }), + owner, + ), + ).toBe(true); + await restarted.flushPendingComments(); + expect(pushes).toBe(1); + expect(restartedOutbox.count()).toBe(0); + restarted.dispose(); + }); + + it.each([ + ["active file first", ["index.html", "stopped.html"]], + ["stopped file first", ["stopped.html", "index.html"]], + ] as const)( + "validates every personal publication in a mixed identity batch (%s)", + async (_order, filePaths) => { + const db = seededDb(); + const owner = context("owner", { + workspaceId: "workspace-personal", + workspaceType: "personal", + workspaceMemberId: "personal-owner", + teamId: "", + }); + const binding: CommentRelayLocalProjectBinding = { + workspaceId: owner.workspaceId, + visibility: "personal", + resourceState: "active", + createdByWorkspaceMemberId: owner.workspaceMemberId, + }; + const publications = createSqlitePublicFilePublicationStore( + db, + () => 100, + ); + for (const filePath of filePaths) { + publications.set( + { + resourceTeamId: owner.workspaceId, + ownerMemberId: owner.workspaceMemberId, + projectId: "p1", + filePath, + }, + { + url: `https://example.test/${filePath}`, + slug: filePath, + fileName: filePath, + }, + ); + } + const scope = ( + projectId: string, + filePath: string, + current: WorkspaceCollabContext, + ) => + commentRelayScope({ + binding, + context: current, + projectId, + filePath, + publications, + }); + const pushed: string[] = []; + const outbox = createCommentRelayOutboxStore(db, () => 100); + const service = createCollabCloudService({ + client: clientWithPush(async (_teamId, _projectId, payload) => { + pushed.push(payload.filePath); + return { seq: pushed.length }; + }), + commentOutbox: outbox, + commentRelayScope: scope, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: owner.workspaceId, + ownerMemberId: owner.workspaceMemberId, + }), + validateCommentRelayProjectBinding: (record) => + commentRelayLocalBindingMatches(record, binding), + resolveCommentRelayWorkspaceContext: async () => owner, + listRemoteProjectRelayBindings: async () => [], + listProjectIds: () => [], + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 100, + retryDelayMs: () => 0, + }); + for (const [index, filePath] of filePaths.entries()) { + expect( + service.enqueueComment( + comment({ + id: `comment-${index}`, + filePath, + authorMemberId: owner.workspaceMemberId, + }), + owner, + ), + ).toBe(true); + } + // This is deliberately after enqueue: the records share one durable owner + // identity batch, but only the active publication may cross the relay. + publications.delete({ + resourceTeamId: owner.workspaceId, + ownerMemberId: owner.workspaceMemberId, + projectId: "p1", + filePath: "stopped.html", + }); + + await service.flushPendingComments(); + + expect(pushed).toEqual(["index.html"]); + expect(outbox.count()).toBe(0); + service.dispose(); + }, + ); + + it("does not let a stale different-creator project block a valid personal project in the same identity batch", async () => { + const db = seededDb(); + const owner = context("owner", { + workspaceId: "workspace-personal", + workspaceType: "personal", + workspaceMemberId: "personal-owner", + teamId: "", + }); + const otherCreator = "another-creator"; + let freshIdentity = owner; + const bindings = new Map([ + [ + "p1", + { + workspaceId: owner.workspaceId, + visibility: "personal", + resourceState: "active", + createdByWorkspaceMemberId: owner.workspaceMemberId, + }, + ], + [ + "p2", + { + workspaceId: owner.workspaceId, + visibility: "personal", + resourceState: "active", + createdByWorkspaceMemberId: otherCreator, + }, + ], + ]); + const publications = createSqlitePublicFilePublicationStore(db, () => 100); + for (const [projectId, creator] of [ + ["p1", owner.workspaceMemberId], + ["p2", otherCreator], + ] as const) { + publications.set( + { + resourceTeamId: owner.workspaceId, + ownerMemberId: creator, + projectId, + filePath: "index.html", + }, + { + url: `https://example.test/${projectId}`, + slug: projectId, + fileName: "index.html", + }, + ); + } + const scope = ( + projectId: string, + filePath: string, + current: WorkspaceCollabContext, + ) => + commentRelayScope({ + binding: bindings.get(projectId), + context: current, + projectId, + filePath, + publications, + }); + const pushed: string[] = []; + const outbox = createCommentRelayOutboxStore(db, () => 100); + const service = createCollabCloudService({ + client: clientWithPush(async (_teamId, projectId) => { + pushed.push(projectId); + return { seq: pushed.length }; + }), + commentOutbox: outbox, + commentRelayScope: scope, + resolveLocalProjectRelayBinding: (projectId) => { + const binding = bindings.get(projectId); + return binding + ? { + workspaceId: binding.workspaceId!, + ownerMemberId: binding.createdByWorkspaceMemberId!, + } + : null; + }, + validateCommentRelayProjectBinding: (record) => + commentRelayLocalBindingMatches(record, bindings.get(record.projectId)), + resolveCommentRelayWorkspaceContext: async () => freshIdentity, + listRemoteProjectRelayBindings: async () => [], + listProjectIds: () => [], + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 100, + retryDelayMs: () => 0, + }); + expect( + service.enqueueComment( + comment({ + id: "valid-p1", + projectId: "p1", + authorMemberId: owner.workspaceMemberId, + }), + owner, + ), + ).toBe(true); + // Simulate a row queued before p2's creator changed: it retains the same + // durable principal batch, but its current creator scope is no longer ours. + outbox.enqueue({ + workspaceId: owner.workspaceId, + workspaceMemberId: owner.workspaceMemberId, + teamId: owner.workspaceId, + relayScope: "personal", + projectId: "p2", + expectedOwnerMemberId: otherCreator, + comment: previewCommentToCloud( + comment({ id: "stale-p2", projectId: "p2" }), + owner.workspaceMemberId, + ), + }); + + await service.flushPendingComments(); + expect(pushed).toEqual(["p1"]); + expect(outbox.count()).toBe(0); + + // A later login/principal transition is not proof of an unpublish; it + // leaves a newly queued active-file row deferred for retry. + expect( + service.enqueueComment( + comment({ + id: "deferred-p1", + projectId: "p1", + authorMemberId: owner.workspaceMemberId, + }), + owner, + ), + ).toBe(true); + freshIdentity = { ...owner, workspaceMemberId: "switched-account" }; + await service.flushPendingComments(); + expect(pushed).toEqual(["p1"]); + expect(outbox.count()).toBe(1); + service.dispose(); + }); + + it.each(["owner", "admin", "member"])( + "delivers %s comments under the exact queued Workspace identity", + async (role) => { + const db = seededDb(); + const queuedContext = context(role); + const calls: Array<{ teamId: string; memberId: string }> = []; + const outbox = createCommentRelayOutboxStore(db, () => 100); + const service = createCollabCloudService({ + client: clientWithPush(async (teamId, _projectId, payload) => { + calls.push({ teamId, memberId: payload.memberId }); + return { seq: 7 }; + }), + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + resolveRemoteProjectOwnerMemberId: async () => "project-owner", + listProjectIds: () => [], + resolveProjectWorkspaceContext: async (_projectId, options) => { + expect(options).toEqual({ fresh: true }); + return queuedContext; + }, + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 100, + retryDelayMs: () => 0, + }); + + expect( + service.enqueueComment( + comment({ authorMemberId: queuedContext.workspaceMemberId }), + queuedContext, + ), + ).toBe(true); + expect(outbox.count()).toBe(1); + await service.flushPendingComments(); + + expect(calls).toEqual([ + { teamId: "team-a", memberId: queuedContext.workspaceMemberId }, + ]); + expect(outbox.count()).toBe(0); + service.dispose(); + }, + ); + + it("retries a failed push after restart and confirms the cloud sequence", async () => { + const db = seededDb(); + const queuedContext = context("member"); + const firstOutbox = createCommentRelayOutboxStore(db, () => 200); + const firstService = createCollabCloudService({ + client: clientWithPush(async () => { + throw new Error("Vela TLS unavailable"); + }), + commentOutbox: firstOutbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + resolveRemoteProjectOwnerMemberId: async () => "project-owner", + listProjectIds: () => [], + resolveProjectWorkspaceContext: async () => queuedContext, + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 200, + retryDelayMs: () => 0, + }); + firstService.enqueueComment(comment(), queuedContext); + await firstService.flushPendingComments(); + expect(firstOutbox.count()).toBe(1); + firstService.dispose(); + + // A new service + newly opened SQLite handle sees and drains the same row. + closeDatabase(); + const reopened = openDatabase(tempDir!); + const reopenedOutbox = createCommentRelayOutboxStore(reopened, () => 200); + const confirmed: Array<{ + commentId: string; + seq: number; + authorKey: string | undefined; + memberId: string; + }> = []; + const secondService = createCollabCloudService({ + client: clientWithPush(async () => ({ + seq: 42, + authorKey: "a".repeat(64), + })), + commentOutbox: reopenedOutbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + resolveRemoteProjectOwnerMemberId: async () => "project-owner", + listProjectIds: () => [], + resolveProjectWorkspaceContext: async () => queuedContext, + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + onCommentPushed: ({ commentId, seq, authorKey, memberId }) => + confirmed.push({ commentId, seq, authorKey, memberId }), + now: () => 200, + retryDelayMs: () => 0, + }); + await secondService.flushPendingComments(); + + expect(reopenedOutbox.count()).toBe(0); + expect(confirmed).toEqual([ + { + commentId: "comment-1", + seq: 42, + authorKey: "a".repeat(64), + memberId: queuedContext.workspaceMemberId, + }, + ]); + secondService.dispose(); + }); + + it("retries in the running daemon after the relay recovers", async () => { + const db = seededDb(); + const queuedContext = context("owner"); + let attempts = 0; + const outbox = createCommentRelayOutboxStore(db, () => 250); + const service = createCollabCloudService({ + client: clientWithPush(async () => { + attempts += 1; + if (attempts === 1) throw new Error("temporary network failure"); + return { seq: 9 }; + }), + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + resolveRemoteProjectOwnerMemberId: async () => "project-owner", + listProjectIds: () => [], + resolveProjectWorkspaceContext: async () => queuedContext, + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 250, + retryDelayMs: () => 0, + }); + service.enqueueComment( + comment({ authorMemberId: "member-owner" }), + queuedContext, + ); + + await service.flushPendingComments(); + expect(attempts).toBe(1); + expect(outbox.count()).toBe(1); + await service.flushPendingComments(); + + expect(attempts).toBe(2); + expect(outbox.count()).toBe(0); + service.dispose(); + }); + + it("coalesces edits and delete tombstones without losing their latest state", async () => { + const db = seededDb(); + const queuedContext = context("admin"); + const pushed: Array<{ note: string; deleted: boolean }> = []; + const outbox = createCommentRelayOutboxStore(db, () => 300); + const service = createCollabCloudService({ + client: clientWithPush(async (_teamId, _projectId, payload) => { + pushed.push({ note: payload.note, deleted: payload.deleted === true }); + return { seq: pushed.length }; + }), + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + resolveRemoteProjectOwnerMemberId: async () => "project-owner", + listProjectIds: () => [], + resolveProjectWorkspaceContext: async () => queuedContext, + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 300, + retryDelayMs: () => 0, + }); + + service.enqueueComment(comment(), queuedContext); + service.enqueueComment( + comment({ note: "edited note", updatedAt: 20 }), + queuedContext, + ); + expect(outbox.count()).toBe(1); + await service.flushPendingComments(); + expect(pushed).toEqual([{ note: "edited note", deleted: false }]); + + service.enqueueCommentDeletion( + comment({ note: "edited note", updatedAt: 20 }), + queuedContext, + ); + await service.flushPendingComments(); + expect(pushed).toEqual([ + { note: "edited note", deleted: false }, + { note: "edited note", deleted: true }, + ]); + expect(outbox.count()).toBe(0); + service.dispose(); + }); + + it("keeps a row pending across identity, Workspace, and Personal mismatches", async () => { + const db = seededDb(); + const queuedContext = context("member"); + let resolved = context("member", { workspaceMemberId: "other-member" }); + let pushes = 0; + const outbox = createCommentRelayOutboxStore(db, () => 400); + const service = createCollabCloudService({ + client: clientWithPush(async () => { + pushes += 1; + return { seq: 1 }; + }), + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + resolveRemoteProjectOwnerMemberId: async () => "project-owner", + listProjectIds: () => [], + resolveProjectWorkspaceContext: async () => resolved, + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 400, + retryDelayMs: () => 0, + }); + service.enqueueComment(comment(), queuedContext); + + await service.flushPendingComments(); + resolved = context("member", { + workspaceId: "workspace-b", + teamId: "team-b", + }); + await service.flushPendingComments(); + resolved = context("member", { + workspaceType: "personal", + workspaceId: "workspace-personal", + }); + delete (resolved as Partial).teamId; + await service.flushPendingComments(); + + expect(pushes).toBe(0); + expect(outbox.count()).toBe(1); + + resolved = queuedContext; + await service.flushPendingComments(); + expect(pushes).toBe(1); + expect(outbox.count()).toBe(0); + service.dispose(); + }); + + it("keeps the delivery pending when the remote catalog is unavailable", async () => { + const db = seededDb(); + const queuedContext = context("member"); + let pushes = 0; + const outbox = createCommentRelayOutboxStore(db, () => 500); + const service = createCollabCloudService({ + client: clientWithPush(async () => { + pushes += 1; + return { seq: 1 }; + }), + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + resolveRemoteProjectOwnerMemberId: async () => { + throw new Error("catalog unavailable"); + }, + listProjectIds: () => [], + resolveProjectWorkspaceContext: async () => queuedContext, + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 500, + retryDelayMs: () => 0, + }); + service.enqueueComment(comment(), queuedContext); + await service.flushPendingComments(); + + expect(pushes).toBe(0); + expect(outbox.count()).toBe(1); + service.dispose(); + }); + + it.each([ + { name: "remote unshare", remoteOwner: null }, + { name: "remote owner conflict", remoteOwner: "different-owner" }, + ])( + "terminally cancels after $name without pushing", + async ({ remoteOwner }) => { + const db = seededDb(); + const queuedContext = context("admin"); + let pushes = 0; + const outbox = createCommentRelayOutboxStore(db, () => 600); + const service = createCollabCloudService({ + client: clientWithPush(async () => { + pushes += 1; + return { seq: 1 }; + }), + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + resolveRemoteProjectOwnerMemberId: async () => remoteOwner, + listProjectIds: () => [], + resolveProjectWorkspaceContext: async () => queuedContext, + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 600, + retryDelayMs: () => 0, + }); + service.enqueueComment(comment(), queuedContext); + await service.flushPendingComments(); + + expect(pushes).toBe(0); + expect(outbox.count()).toBe(0); + service.dispose(); + }, + ); + + it("reports a missing remote owner while conditionally acknowledging only that revision", async () => { + const db = seededDb(); + const queuedContext = context("member"); + const outbox = createCommentRelayOutboxStore(db, () => 700); + const catalog = + deferred>(); + const pushed: string[] = []; + const confirmed: string[] = []; + const errors: unknown[] = []; + const service = createCollabCloudService({ + client: clientWithPush(async (_teamId, projectId) => { + pushed.push(projectId); + return { seq: pushed.length }; + }), + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + listProjectIds: () => [], + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + resolveCommentRelayWorkspaceContext: async () => queuedContext, + listRemoteProjectRelayBindings: async () => catalog.promise, + onCommentPushed: ({ projectId }) => confirmed.push(projectId), + onError: (error) => errors.push(error), + now: () => 700, + retryDelayMs: () => 0, + }); + expect( + service.enqueueComment( + comment({ id: "missing-owner", note: "old revision" }), + queuedContext, + ), + ).toBe(true); + expect( + service.enqueueComment( + comment({ id: "valid-project", projectId: "p2" }), + queuedContext, + ), + ).toBe(true); + + const flushing = service.flushPendingComments(); + await Promise.resolve(); + expect( + service.enqueueComment( + comment({ id: "missing-owner", note: "new revision", updatedAt: 20 }), + queuedContext, + ), + ).toBe(true); + catalog.resolve([{ projectId: "p2", ownerMemberId: "project-owner" }]); + await flushing; + + expect(pushed).toEqual(["p2"]); + expect(confirmed).toEqual(["p2"]); + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(Error); + expect((errors[0] as Error).message).toContain( + "remote project owner missing", + ); + expect(outbox.count()).toBe(1); + expect(outbox.listDue(700)[0]?.comment.note).toBe("new revision"); + service.dispose(); + }); + + it("reuses one fresh authority and one catalog snapshot for an exact identity batch", async () => { + const db = seededDb(); + const queuedContext = context("member"); + let timestamp = 700; + let authorityReads = 0; + let catalogReads = 0; + let legacyAuthorityReads = 0; + let legacyCatalogReads = 0; + const pushed: string[] = []; + const outbox = createCommentRelayOutboxStore(db, () => timestamp++); + const deps = Object.assign( + { + client: clientWithPush(async (_teamId, projectId, payload) => { + pushed.push(`${projectId}:${payload.id}`); + return { seq: pushed.length }; + }), + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + resolveProjectWorkspaceContext: async () => { + legacyAuthorityReads += 1; + return queuedContext; + }, + resolveRemoteProjectOwnerMemberId: async () => { + legacyCatalogReads += 1; + return "project-owner"; + }, + listProjectIds: () => [], + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 1_000, + retryDelayMs: () => 0, + }, + { + resolveCommentRelayWorkspaceContext: async () => { + authorityReads += 1; + return queuedContext; + }, + listRemoteProjectRelayBindings: async () => { + catalogReads += 1; + return [ + { projectId: "p1", ownerMemberId: "project-owner" }, + { projectId: "p2", ownerMemberId: "project-owner" }, + ]; + }, + }, + ); + const service = createCollabCloudService(deps); + + for (const [projectId, commentId] of [ + ["p1", "comment-1"], + ["p1", "comment-2"], + ["p2", "comment-3"], + ["p2", "comment-4"], + ] as const) { + expect( + service.enqueueComment( + comment({ id: commentId, projectId }), + queuedContext, + ), + ).toBe(true); + } + + await service.flushPendingComments(); + + expect(authorityReads).toBe(1); + expect(catalogReads).toBe(1); + expect(legacyAuthorityReads).toBe(0); + expect(legacyCatalogReads).toBe(0); + expect(pushed).toHaveLength(4); + expect(outbox.count()).toBe(0); + service.dispose(); + }); + + it("bounds independent project pushes while preserving each project order", async () => { + const db = seededDb(); + const queuedContext = context("member"); + let timestamp = 800; + let activePushes = 0; + let maxActivePushes = 0; + const started: string[] = []; + const deliveries = new Map( + [ + "p1:comment-1", + "p1:comment-2", + "p2:comment-3", + "p3:comment-4", + "p4:comment-5", + "p5:comment-6", + ].map((key) => [key, deferred<{ seq: number }>()]), + ); + const outbox = createCommentRelayOutboxStore(db, () => timestamp++); + const deps = Object.assign( + { + client: clientWithPush(async (_teamId, projectId, payload) => { + const key = `${projectId}:${payload.id}`; + started.push(key); + activePushes += 1; + maxActivePushes = Math.max(maxActivePushes, activePushes); + try { + return await deliveries.get(key)!.promise; + } finally { + activePushes -= 1; + } + }), + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + resolveProjectWorkspaceContext: async () => queuedContext, + resolveRemoteProjectOwnerMemberId: async () => "project-owner", + listProjectIds: () => [], + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 1_000, + retryDelayMs: () => 0, + }, + { + resolveCommentRelayWorkspaceContext: async () => queuedContext, + listRemoteProjectRelayBindings: async () => + ["p1", "p2", "p3", "p4", "p5"].map((projectId) => ({ + projectId, + ownerMemberId: "project-owner", + })), + }, + ); + const service = createCollabCloudService(deps); + for (const [projectId, commentId] of [ + ["p1", "comment-1"], + ["p1", "comment-2"], + ["p2", "comment-3"], + ["p3", "comment-4"], + ["p4", "comment-5"], + ["p5", "comment-6"], + ] as const) { + expect( + service.enqueueComment( + comment({ id: commentId, projectId }), + queuedContext, + ), + ).toBe(true); + } + + const flushing = service.flushPendingComments(); + try { + await waitForCondition(() => started.length >= 4); + expect(started).toEqual([ + "p1:comment-1", + "p2:comment-3", + "p3:comment-4", + "p4:comment-5", + ]); + expect(started).not.toContain("p1:comment-2"); + expect(maxActivePushes).toBe(4); + + deliveries.get("p2:comment-3")!.resolve({ seq: 3 }); + await waitForCondition(() => started.includes("p5:comment-6")); + expect(started).not.toContain("p1:comment-2"); + + deliveries.get("p1:comment-1")!.resolve({ seq: 1 }); + await waitForCondition(() => started.includes("p1:comment-2")); + expect(maxActivePushes).toBe(4); + } finally { + let seq = 10; + for (const delivery of deliveries.values()) { + delivery.resolve({ seq }); + seq += 1; + } + await flushing; + service.dispose(); + } + + expect(outbox.count()).toBe(0); + }); + + it("keeps a newer revision queued when a batched push acknowledges an older payload", async () => { + const db = seededDb(); + const queuedContext = context("member"); + let timestamp = 900; + const firstPush = deferred<{ seq: number }>(); + const pushedNotes: string[] = []; + const outbox = createCommentRelayOutboxStore(db, () => timestamp++); + const deps = Object.assign( + { + client: clientWithPush(async (_teamId, _projectId, payload) => { + pushedNotes.push(payload.note); + if (pushedNotes.length === 1) return firstPush.promise; + return { seq: 2 }; + }), + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + listProjectIds: () => [], + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 1_000, + retryDelayMs: () => 0, + }, + { + resolveCommentRelayWorkspaceContext: async () => queuedContext, + listRemoteProjectRelayBindings: async () => [ + { + projectId: "p1", + ownerMemberId: "project-owner", + }, + ], + }, + ); + const service = createCollabCloudService(deps); + service.enqueueComment(comment(), queuedContext); + + const firstFlush = service.flushPendingComments(); + await waitForCondition(() => pushedNotes.length === 1); + service.enqueueComment( + comment({ note: "newer note", updatedAt: 20 }), + queuedContext, + ); + firstPush.resolve({ seq: 1 }); + await firstFlush; + + expect(outbox.count()).toBe(1); + await service.flushPendingComments(); + expect(pushedNotes).toEqual(["first note", "newer note"]); + expect(outbox.count()).toBe(0); + service.dispose(); + }); + + it("does not lose a drain request when a newer same-project revision arrives during push", async () => { + const db = seededDb(); + const queuedContext = context("member"); + let timestamp = 950; + const firstPush = deferred<{ seq: number }>(); + const pushedNotes: string[] = []; + const outbox = createCommentRelayOutboxStore(db, () => timestamp++); + const deps = Object.assign( + { + client: clientWithPush(async (_teamId, _projectId, payload) => { + pushedNotes.push(payload.note); + if (pushedNotes.length === 1) return firstPush.promise; + return { seq: 2 }; + }), + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + listProjectIds: () => [], + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 1_000, + retryDelayMs: () => 0, + }, + { + resolveCommentRelayWorkspaceContext: async () => queuedContext, + listRemoteProjectRelayBindings: async () => [ + { + projectId: "p1", + ownerMemberId: "project-owner", + }, + ], + }, + ); + const service = createCollabCloudService(deps); + service.enqueueComment(comment(), queuedContext); + + const firstFlush = service.flushPendingComments(); + await waitForCondition(() => pushedNotes.length === 1); + service.enqueueComment( + comment({ note: "newer note", updatedAt: 20 }), + queuedContext, + ); + // This is the same signal queuedCloudComment schedules in production. + // It must join the active drain instead of becoming a dropped no-op. + await service.flushPendingComments(); + firstPush.resolve({ seq: 1 }); + await firstFlush; + + expect(pushedNotes).toEqual(["first note", "newer note"]); + expect(outbox.count()).toBe(0); + service.dispose(); + }); + + it("fails a batch closed before catalog or push when its fresh identity changed", async () => { + const db = seededDb(); + const queuedContext = context("member"); + let catalogReads = 0; + let pushes = 0; + const outbox = createCommentRelayOutboxStore(db, () => 1_000); + const deps = Object.assign( + { + client: clientWithPush(async () => { + pushes += 1; + return { seq: 1 }; + }), + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + listProjectIds: () => [], + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 1_000, + retryDelayMs: () => 0, + }, + { + resolveCommentRelayWorkspaceContext: async () => + context("member", { workspaceMemberId: "different-member" }), + listRemoteProjectRelayBindings: async () => { + catalogReads += 1; + return [{ projectId: "p1", ownerMemberId: "project-owner" }]; + }, + }, + ); + const service = createCollabCloudService(deps); + service.enqueueComment(comment(), queuedContext); + + await service.flushPendingComments(); + + expect(catalogReads).toBe(0); + expect(pushes).toBe(0); + expect(outbox.count()).toBe(1); + service.dispose(); + }); + + it("does not let a stalled outbound batch block inbound comment pulls", async () => { + const db = seededDb(); + const queuedContext = context("member"); + const stalledPush = deferred<{ seq: number }>(); + let pushStarted = false; + let pulls = 0; + const outbox = createCommentRelayOutboxStore(db, () => 1_100); + const client = { + pushComment: async () => { + pushStarted = true; + return stalledPush.promise; + }, + registerMember: async () => ({ + memberId: "member-member", + displayName: "member", + role: "member" as const, + }), + pullComments: async () => { + pulls += 1; + return { + comments: [], + latestSeq: 0, + etag: null, + notModified: true, + }; + }, + } as unknown as CollabCloudClient; + const deps = Object.assign( + { + client, + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + listProjectIds: () => ["p1"], + resolveProjectWorkspaceContext: async () => queuedContext, + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 1_100, + retryDelayMs: () => 0, + }, + { + resolveCommentRelayWorkspaceContext: async () => queuedContext, + listRemoteProjectRelayBindings: async () => [ + { + projectId: "p1", + ownerMemberId: "project-owner", + }, + ], + }, + ); + const service = createCollabCloudService(deps); + service.enqueueComment(comment(), queuedContext); + + const polling = service.pollOnce(); + try { + await waitForCondition(() => pushStarted); + await waitForCondition(() => pulls === 1); + expect(pulls).toBe(1); + } finally { + stalledPush.resolve({ seq: 1 }); + await polling; + service.dispose(); + } + }); + + it.each<{ + name: string; + binding: CommentRelayLocalProjectBinding; + }>([ + { + name: "local unshare", + binding: { + workspaceId: "workspace-a", + visibility: "personal", + resourceState: "active", + createdByWorkspaceMemberId: "project-owner", + }, + }, + { + name: "local deletion", + binding: { + workspaceId: "workspace-a", + visibility: "team", + resourceState: "deleted", + createdByWorkspaceMemberId: "project-owner", + }, + }, + { + name: "local owner mismatch", + binding: { + workspaceId: "workspace-a", + visibility: "team", + resourceState: "active", + createdByWorkspaceMemberId: "different-owner", + }, + }, + ])( + "does not push after $name even while the remote catalog is stale", + async ({ binding }) => { + const db = seededDb(); + const queuedContext = context("member"); + let pushes = 0; + const outbox = createCommentRelayOutboxStore(db, () => 1_200); + const deps = Object.assign( + { + client: clientWithPush(async () => { + pushes += 1; + return { seq: 1 }; + }), + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + validateCommentRelayProjectBinding: ( + record: Parameters[0], + ) => commentRelayLocalBindingMatches(record, binding), + listProjectIds: () => [], + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 1_200, + retryDelayMs: () => 0, + }, + { + resolveCommentRelayWorkspaceContext: async () => queuedContext, + listRemoteProjectRelayBindings: async () => [ + { + projectId: "p1", + ownerMemberId: "project-owner", + }, + ], + }, + ); + const service = createCollabCloudService(deps); + service.enqueueComment(comment(), queuedContext); + + await service.flushPendingComments(); + + expect(pushes).toBe(0); + expect(outbox.count()).toBe(0); + service.dispose(); + }, + ); + + it("handles a detached outbox failure after dispose without another delivery", async () => { + const db = seededDb(); + const queuedContext = context("member"); + const stalledPush = deferred<{ seq: number }>(); + const errors: unknown[] = []; + let pushStarted = false; + let confirmations = 0; + const outbox = createCommentRelayOutboxStore(db, () => 1_300); + const client = { + pushComment: async () => { + pushStarted = true; + return stalledPush.promise; + }, + registerMember: async () => ({ + memberId: "member-member", + displayName: "member", + role: "member" as const, + }), + pullComments: async () => ({ + comments: [], + latestSeq: 0, + etag: null, + notModified: true, + }), + } as unknown as CollabCloudClient; + const deps = Object.assign( + { + client, + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + listProjectIds: () => ["p1"], + resolveProjectWorkspaceContext: async () => queuedContext, + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + onCommentPushed: () => { + confirmations += 1; + }, + onError: (error: unknown) => errors.push(error), + now: () => 1_300, + retryDelayMs: () => 0, + }, + { + resolveCommentRelayWorkspaceContext: async () => queuedContext, + listRemoteProjectRelayBindings: async () => [ + { + projectId: "p1", + ownerMemberId: "project-owner", + }, + ], + }, + ); + const service = createCollabCloudService(deps); + service.enqueueComment(comment(), queuedContext); + + await service.pollOnce(); + await waitForCondition(() => pushStarted); + service.dispose(); + stalledPush.reject(new Error("relay stopped during shutdown")); + await waitForCondition(() => errors.length === 1); + + expect(confirmations).toBe(0); + expect(outbox.count()).toBe(1); + }); + + it("acknowledges only the rejected revision for a structured 410 SHARE_STOPPED and does not resurrect it after SQLite reopen", async () => { + const db = seededDb(); + const queuedContext = context("member"); + const outbox = createCommentRelayOutboxStore(db, () => 1_400); + const stdout = fs.readFileSync( + new URL( + "./fixtures/vela-cli-comment-push-share-stopped-927e0a62e7.stdout.json", + import.meta.url, + ), + "utf8", + ); + // Exercise the default runner: runVelaCommand captures stdout on its + // rejected process-boundary error, then defaultRunVelaCollab must preserve + // the structured terminal failure through runJson and the SQLite outbox. + vi.stubEnv("VELA_BIN", process.execPath); + vi.stubEnv("OD_DATA_DIR", ""); + execFileMock.mockImplementationOnce((_bin, _args, _options, callback) => { + callback(new Error("unclassified command failure"), stdout, ""); + return { pid: 4321 }; + }); + const client = createVelaCliCollabClient(); + const errors: unknown[] = []; + const confirmed: Array<{ commentId: string; seq: number }> = []; + const service = createCollabCloudService({ + client, + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + resolveRemoteProjectOwnerMemberId: async () => "project-owner", + listProjectIds: () => [], + resolveProjectWorkspaceContext: async () => queuedContext, + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + onCommentPushed: (event) => confirmed.push(event), + onError: (error) => errors.push(error), + now: () => 1_400, + retryDelayMs: () => 0, + }); + expect(service.enqueueComment(comment(), queuedContext)).toBe(true); + await service.flushPendingComments(); + + expect(outbox.count()).toBe(0); + expect(confirmed).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(CollabCloudError); + expect(errors[0]).toMatchObject({ status: 410, code: "SHARE_STOPPED" }); + service.dispose(); + closeDatabase(); + + const reopened = openDatabase(tempDir!); + const restartedOutbox = createCommentRelayOutboxStore( + reopened, + () => 1_400, + ); + expect(restartedOutbox.count()).toBe(0); + }); + + it("keeps a newer queued revision when the in-flight older revision is terminally rejected", async () => { + const db = seededDb(); + const queuedContext = context("member"); + const outbox = createCommentRelayOutboxStore(db, () => 1_500); + const inFlight = deferred(); + const service = createCollabCloudService({ + client: clientWithPush(async () => { + await inFlight.promise; + throw new CollabCloudError(410, "SHARE_STOPPED"); + }), + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + resolveRemoteProjectOwnerMemberId: async () => "project-owner", + listProjectIds: () => [], + resolveProjectWorkspaceContext: async () => queuedContext, + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 1_500, + retryDelayMs: () => 0, + }); + service.enqueueComment(comment({ note: "old" }), queuedContext); + const flushing = service.flushPendingComments(); + await Promise.resolve(); + service.enqueueComment(comment({ note: "new" }), queuedContext); + inFlight.resolve(); + await flushing; + + const [pending] = outbox.listDue(1_500); + expect(pending?.comment.note).toBe("new"); + expect(outbox.count()).toBe(1); + service.dispose(); + }); + + it.each([ + new CollabCloudError(429, "RATE_LIMITED", "SHARE_STOPPED"), + new CollabCloudError(500, "UPSTREAM_FAILURE", "SHARE_STOPPED"), + new CollabCloudError(410, "ANOTHER_GONE_CODE", "SHARE_STOPPED"), + new Error("410 SHARE_STOPPED"), + ])( + "retries non-terminal structured failures without using message text", + async (failure) => { + const db = seededDb(); + const queuedContext = context("member"); + const outbox = createCommentRelayOutboxStore(db, () => 1_600); + let attempts = 0; + const service = createCollabCloudService({ + client: clientWithPush(async () => { + attempts += 1; + throw failure; + }), + commentOutbox: outbox, + resolveLocalProjectRelayBinding: () => ({ + workspaceId: "workspace-a", + ownerMemberId: "project-owner", + }), + resolveRemoteProjectOwnerMemberId: async () => "project-owner", + listProjectIds: () => [], + resolveProjectWorkspaceContext: async () => queuedContext, + resolveLocalConversationId: () => "conv-local", + mergeComment: () => "unchanged", + now: () => 1_600, + retryDelayMs: () => 0, + }); + service.enqueueComment(comment(), queuedContext); + await service.flushPendingComments(); + await service.flushPendingComments(); + await service.flushPendingComments(); + expect(attempts).toBe(3); + expect(outbox.count()).toBe(1); + service.dispose(); + }, + ); }); diff --git a/apps/daemon/tests/comment-sync-state.test.ts b/apps/daemon/tests/comment-sync-state.test.ts index 588b5b2bfd..f2cf016fd5 100644 --- a/apps/daemon/tests/comment-sync-state.test.ts +++ b/apps/daemon/tests/comment-sync-state.test.ts @@ -36,7 +36,11 @@ it('supplies scoped K8 over HTTP with unknown, login recovery, isolated retry an } const mine = queue.listDue(1).find(row => row.commentId === 'mine')!; queue.defer(mine, { error: 'secret-token-do-not-expose', nextAttemptAt: 9000000000000 }); - const foreignBefore = db.prepare("SELECT * FROM comment_relay_outbox WHERE comment_id!='mine'").all(); + // Legacy rows may acquire durable idempotency keys on read; retry must not + // mutate foreign business data or their delivery scheduling. + const foreignRows = () => db.prepare("SELECT * FROM comment_relay_outbox WHERE comment_id!='mine'").all() + .map(row => { const business = { ...row as Record }; delete business.event_key; return business; }); + const foreignBefore = foreignRows(); const service = createCommentSyncStateService(db, async () => { if (fail) throw new Error('session unavailable'); return available; }); registerCommentSyncStateRoutes(app, { db, service, authorize: async req => req.get('x-test-deny') ? { ok: false, status: 403, code: 'DENIED', message: 'denied' } : { ok: true, context } }); @@ -53,9 +57,11 @@ it('supplies scoped K8 over HTTP with unknown, login recovery, isolated retry an const denied = await fetch(url, { method: 'POST', headers: { 'x-test-deny': '1' } }); expect(denied.status).toBe(403); expect(queue.listDue(1).some(row => row.commentId === 'mine')).toBe(false); + const foreignKeys = db.prepare("SELECT comment_id, event_key FROM comment_relay_outbox WHERE comment_id!='mine'").all(); expect((await fetch(url, { method: 'POST' })).status).toBe(200); expect(queue.listDue(1).find(row => row.commentId === 'mine')?.revision).toBe(mine.revision); - expect(db.prepare("SELECT * FROM comment_relay_outbox WHERE comment_id!='mine'").all()).toEqual(foreignBefore); + expect(foreignRows()).toEqual(foreignBefore); + expect(db.prepare("SELECT comment_id, event_key FROM comment_relay_outbox WHERE comment_id!='mine'").all()).toEqual(foreignKeys); queue.acknowledge(mine); available = false; expect(await (await fetch(url)).json()).toEqual({ pending: 0, lastError: 'COMMENT_SYNC_DELIVERY_FAILED', sessionMissing: false, shareStopped: null }); available = true; diff --git a/apps/daemon/tests/comment-sync-stopped-state.test.ts b/apps/daemon/tests/comment-sync-stopped-state.test.ts new file mode 100644 index 0000000000..f973cb5081 --- /dev/null +++ b/apps/daemon/tests/comment-sync-stopped-state.test.ts @@ -0,0 +1,90 @@ +import { expect, it, vi } from 'vitest'; +import express from 'express'; +import { createServer } from 'node:http'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { WorkspaceCollabContext } from '@open-design/contracts'; +import { closeDatabase, openDatabase, insertProject } from '../src/db.js'; +import { createCommentSyncStateService } from '../src/collab/comment-sync-state.js'; +import { registerCommentSyncStateRoutes } from '../src/routes/project/comments.js'; +import { createVelaProjectShareState } from '../src/collab/vela-project-share-state.js'; + +const slug = '93a3c7e6-198d-4b72-9f70-0bbfdf9f9c55'; +const scope = { projectId: 'p', workspaceId: 'w', workspaceMemberId: 'owner', filePath: 'pages/a.html' }; +const remote = (status: 'active' | 'stopped') => ({ projectId: 'p', bindingExists: true, hasEverShared: true, + publications: [{ sourceFilePath: scope.filePath, slug, status }] }); + +it('reads K3 from authenticated per-file cloud history after stop, never from missing local rows', async () => { + const root = mkdtempSync(join(tmpdir(), 'od-k3-state-')); + const db = openDatabase(root); + const app = express(); + const server = createServer(app); + let answer = remote('active'); + let cloudUnavailable = false; + let session: boolean | null = true; + let credential = { apiUrl: 'https://example.test', controlKey: 'account-a' }; + let rotateDuringAvailability = false; + let rotateDuringRemoteRead = false; + const reader = vi.fn(async (input: { projectId: string; resourceTeamId: string; ownerMemberId: string }) => { + expect(input).toEqual({ projectId: 'p', resourceTeamId: 'w', ownerMemberId: 'owner' }); + if (cloudUnavailable) throw new Error('secret-token-must-not-leak'); + if (rotateDuringRemoteRead) credential = { ...credential, controlKey: 'account-b' }; + return answer; + }); + try { + insertProject(db, { id: 'p', name: 'P', createdAt: 1, updatedAt: 1 }); + const service = createCommentSyncStateService(db, async () => { + if (rotateDuringAvailability) credential = { ...credential, controlKey: 'account-b' }; + return session; + }, { readProjectShareState: reader, readCredential: () => credential }); + registerCommentSyncStateRoutes(app, { db, service, authorize: async req => req.get('x-test-deny') + ? { ok: false, status: 403, code: 'DENIED', message: 'denied' } + : { ok: true, context: { workspaceId: 'w', workspaceMemberId: 'owner' } as WorkspaceCollabContext } }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); if (!address || typeof address === 'string') throw new Error('listener unavailable'); + const base = `http://127.0.0.1:${address.port}/api/projects/p/comment-sync-state`; + const get = async (filePath = scope.filePath) => { + const response = await fetch(`${base}?filePath=${encodeURIComponent(filePath)}`); + expect(response.status).toBe(200); + return response.json() as Promise<{ shareStopped: boolean | null }>; + }; + expect((await get()).shareStopped).toBe(false); + answer = remote('stopped'); + expect((await get()).shareStopped).toBe(true); + expect((await get('pages/other.html')).shareStopped).toBeNull(); + expect(((await (await fetch(base)).json()) as { shareStopped: boolean | null }).shareStopped).toBeNull(); + expect((await fetch(`${base}?filePath=${encodeURIComponent(scope.filePath)}`, { headers: { 'x-test-deny': '1' } })).status).toBe(403); + expect(reader).toHaveBeenCalledTimes(3); + cloudUnavailable = true; + expect((await get()).shareStopped).toBeNull(); + cloudUnavailable = false; + session = false; + expect((await get()).shareStopped).toBeNull(); + session = null; + expect(await (await fetch(`${base}?filePath=${encodeURIComponent(scope.filePath)}`)).json()).toBeNull(); + expect(reader).toHaveBeenCalledTimes(4); + answer = remote('active'); session = true; + expect((await get()).shareStopped).toBe(false); + rotateDuringRemoteRead = true; + expect((await get()).shareStopped).toBeNull(); + rotateDuringRemoteRead = false; + credential = { ...credential, controlKey: 'account-a' }; + rotateDuringAvailability = true; + expect((await get()).shareStopped).toBeNull(); + } finally { + if (server.listening) await new Promise(resolve => server.close(() => resolve())); + closeDatabase(); rmSync(root, { recursive: true, force: true }); + } +}); + +it('cloud history for comment sync is read-only even for a personal project with proven local ownership', async () => { + const session = { profile: 'test' as const, apiUrl: 'https://example.test', controlKey: 'synthetic', user: null, configMtimeMs: null }; + const runCommand = vi.fn(async () => JSON.stringify({ projectId: 'p', bindingExists: false, publications: [] })); + const read = createVelaProjectShareState({ dataRoot: tmpdir(), configuredEnv: {}, readSession: () => session, + resolveLocalProjectOwner: () => 'owner', registerPersonalProject: false, + fetchDirectory: async () => ({ ok: true, items: [{ workspaceId: 'w', workspaceName: 'W', workspaceType: 'personal', workspaceMemberId: 'owner', role: 'owner', memberStatus: 'active', lifecycleState: 'active' }] }), + runCommand }); + expect((await read({ projectId: 'p', resourceTeamId: 'w', ownerMemberId: 'owner' })).bindingExists).toBe(false); + expect(runCommand).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ args: ['share', 'project-status', 'p', '--json'] })); +}); diff --git a/apps/daemon/tests/project-cli.test.ts b/apps/daemon/tests/project-cli.test.ts index 21c4d1f945..9e10edcb3b 100644 --- a/apps/daemon/tests/project-cli.test.ts +++ b/apps/daemon/tests/project-cli.test.ts @@ -112,6 +112,11 @@ async function startProjectStubServer(deleteResponse: unknown = { ok: true }, sh })); return; } + if (captured.method === 'POST' && captured.url === '/api/projects/project-1/files/nested%2Findex.html/share-plan') { + res.statusCode = 200; + res.end(JSON.stringify({ fileCount: 1, totalBytes: 12, exceedsSizeLimit: false, exclusions: [{ path: 'assets/missing.png', reason: 'missing' }] })); + return; + } if (['POST', 'GET'].includes(captured.method) && captured.url === '/api/projects/project-1/files/nested%2Findex.html/publish-public') { if (shareResponse !== undefined) { res.statusCode = shareStatus; res.end(JSON.stringify(shareResponse)); return; } @@ -319,6 +324,15 @@ describe('od project CLI', () => { expect(stub.requests).toHaveLength(1); expect(stub.requests[0]).toMatchObject({ method: 'GET', url: '/api/projects/project-1/share-state', headers: { 'x-od-workspace-id': 'ws-1', 'x-od-workspace-member-id': 'member-1' } }); }); + it('share preflight uses the same HTTP plan endpoint and returns its JSON DTO', async () => { + stub = await startProjectStubServer(); + const result = await runCli(['project', 'share', 'preflight', 'project-1', '--path', 'nested/index.html', '--workspace', 'ws-1', '--workspace-member', 'member-1', '--daemon-url', stub.baseUrl, '--json']); + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({ fileCount: 1, totalBytes: 12, exceedsSizeLimit: false, exclusions: [{ path: 'assets/missing.png', reason: 'missing' }] }); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]).toMatchObject({ method: 'POST', url: '/api/projects/project-1/files/nested%2Findex.html/share-plan', headers: { 'x-od-workspace-id': 'ws-1', 'x-od-workspace-member-id': 'member-1' }, body: '' }); + }); it.each(['publish', 'resume', 'get', 'status', 'stop'])('share %s uses the existing file endpoint and emits raw JSON', async action => { stub = await startProjectStubServer(); const result = await runCli([ @@ -338,13 +352,15 @@ describe('od project CLI', () => { method: action === 'stop' ? 'DELETE' : ['publish', 'resume'].includes(action) ? 'POST' : 'GET', url: '/api/projects/project-1/files/nested%2Findex.html/publish-public', headers: { 'x-od-workspace-id': 'ws-1', 'x-od-workspace-member-id': 'member-1' }, - body: action === 'stop' ? JSON.stringify({ slug: 'legacy-public-slug' }) : '', + body: action === 'stop' ? JSON.stringify({ slug: 'legacy-public-slug' }) + : action === 'resume' ? JSON.stringify({ mode: 'resume' }) : '', }); }); it('resume is documented and requires a file before making requests', async () => { stub = await startProjectStubServer(); const help = await runCli(['project', 'share', '--help']); expect(help.stdout).toContain('od project share resume --path [--json]'); + expect(help.stdout).toContain('od project share preflight --path [--json]'); const invalid = await runCli(['project', 'share', 'resume', 'project-1', '--daemon-url', stub.baseUrl, '--json']); expect(invalid.code).toBe(2); expect(stub.requests).toHaveLength(0); diff --git a/apps/daemon/tests/public-comment-anchor.test.ts b/apps/daemon/tests/public-comment-anchor.test.ts index c9f4742e67..7ab0a8df9b 100644 --- a/apps/daemon/tests/public-comment-anchor.test.ts +++ b/apps/daemon/tests/public-comment-anchor.test.ts @@ -7,6 +7,7 @@ import { closeDatabase, openDatabase, insertProject, insertConversation, ensureW import { createSqlitePublicFilePublicationStore } from '../src/collab/public-file-publication-store.js'; import { createPublicFilePublicationRecorder } from '../src/collab/public-file-publication-recording.js'; import { enqueuePublishedFileComments } from '../src/collab/published-file-comment-backfill.js'; +import { readPublishedCommentBackfill } from '../src/collab/published-comment-backfill-state.js'; import { createShareFileMapping } from '../src/collab/share-file-mapping.js'; import { recordCommentRelayPublicationMapping, sourcePathForCurrentPublication } from '../src/collab/comment-relay-publication-mapping.js'; import { personalCommentRelayFilePaths } from '../src/collab/comment-relay-scope.js'; @@ -73,6 +74,12 @@ it('first personal publication makes public user comments pullable into the shar expect(getProjectCommentAnchorConversationId(db, scope.projectId)).toBeNull(); const record = createPublicFilePublicationRecorder(db, publications, enqueuePublishedFileComments); record(scope, publication, mapping); + // K1: no existing comments skips waiting/syncing without losing the link. + expect(publications.get(scope)).toMatchObject(publication); + expect(readPublishedCommentBackfill(db, { projectId: scope.projectId, workspaceId: scope.resourceTeamId, + workspaceMemberId: scope.ownerMemberId, filePath: scope.filePath })).toMatchObject({ + state: 'succeeded', retryable: false, publicationRevision: publications.getRevision(scope)?.token, + }); await expectInbound(db); const anchor = getProjectCommentAnchorConversationId(db, scope.projectId); record(scope, publication, mapping); diff --git a/apps/daemon/tests/public-share-origin-http.test.ts b/apps/daemon/tests/public-share-origin-http.test.ts index 866e6c32fd..87715b9c15 100644 --- a/apps/daemon/tests/public-share-origin-http.test.ts +++ b/apps/daemon/tests/public-share-origin-http.test.ts @@ -61,6 +61,15 @@ for (const scenario of cases) it.each([false, true])(`${scenario.name}: HTTP pub expect(response.status).toBe(502); expect(body).toEqual({ error: 'PUBLIC_FILE_PUBLISH_UNAVAILABLE', failure: { stage: 'push', reason: 'unknown' } }); expect(store.getRevision(scope)).toBeNull(); expect(createShareBindingOutbox(db).list()).toEqual([]); return; } + // A stale two-step CLI can return binding_pending after advancing the + // remote alias. Never record or advertise that as an atomic publication. + if (pending) { + expect(response.status).toBe(502); + expect(body).toEqual({ error: 'PUBLIC_FILE_PUBLISH_UNAVAILABLE', failure: { stage: 'snapshot', reason: 'unknown' } }); + expect(store.getRevision(scope)).toBeNull(); + expect(createShareBindingOutbox(db).list()).toEqual([]); + return; + } expect(response.status).toBe(200); expect(uploads).toBe(1); expect(body.status).toBe(pending ? 'binding_pending' : 'published'); expect(body.receipt).toEqual({ filePath: 'index.html', slug: fixtureShareSlug, publishedAt: 1, version: 1, versionId: 'version-1', entryPath: 'index.html' }); @@ -103,7 +112,7 @@ for (const scenario of cases) it.each([false, true])(`${scenario.name}: HTTP pub } finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); runtime.dispose(); db.close(); await rm(root, { recursive: true, force: true }); } }); -it.each([false, true])('HTTP owner republish resumes stopped alias; initial resume failure=%s', async failResume => { +it('HTTP owner republish rejects a stopped alias if the old CLI returns binding_pending', async () => { const root = await mkdtemp(join(tmpdir(), 'od-resume-http-')); const db = new Database(':memory:'); migratePublicFilePublications(db); migrateCommentRelayOutbox(db); @@ -114,7 +123,7 @@ it.each([false, true])('HTTP owner republish resumes stopped alias; initial resu seatSummary: buildWorkspaceSeatSummary({ seatLimit: 1, usedSeats: 1 }), permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }), }; const runtime = createCollabRuntime({ workspaceContext: { current: async () => context } }); - const options = { failResume, commands: [] as string[][] }; + const options = { commands: [] as string[][] }; let uploads = 0; const fixture = createPublicSharePublishingFixture(db, store, async () => JSON.stringify({ id: `version-${++uploads}`, version: uploads }), undefined, options); const app = express(); app.use(express.json()); @@ -131,25 +140,140 @@ it.each([false, true])('HTTP owner republish resumes stopped alias; initial resu expect((await fetch(url, { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slug: fixtureShareSlug }) })).status).toBe(200); const scope = { resourceTeamId: 'w', ownerMemberId: 'owner', projectId: 'p', filePath: 'index.html' }; expect((await fixture.readProjectShareState!(scope)).publications[0]?.status).toBe('stopped'); - const start = options.commands.length; - const republished = await (await fetch(url, { method: 'POST' })).json(); - assertJsonObject(republished); - expect(republished).toMatchObject({ status: failResume ? 'binding_pending' : 'published' }); - expect(options.commands.slice(start).map(args => args.slice(0, 2))).toEqual([['resource', 'push'], ['share', 'publish'], ['share', 'resume']]); const revision = store.getRevision(scope); - if (failResume) { - options.failResume = false; - const before = options.commands.length; - expect(await (await fetch(url, { method: 'POST' })).json()).toMatchObject({ status: 'published', receipt: republished.receipt }); - expect(options.commands.slice(before).map(args => args.slice(0, 2))).toEqual([['share', 'resume']]); - expect(store.getRevision(scope)).toEqual(revision); - } + const start = options.commands.length; + const response = await fetch(url, { method: 'POST' }); + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ error: 'PUBLIC_FILE_PUBLISH_UNAVAILABLE', failure: { stage: 'snapshot', reason: 'unknown' } }); + expect(options.commands.slice(start).map(args => args.slice(0, 2))).toEqual([['resource', 'push'], ['share', 'publish']]); + expect(store.getRevision(scope)).toEqual(revision); expect(uploads).toBe(2); - expect((await fixture.readProjectShareState!(scope)).publications[0]).toMatchObject({ status: 'active', slug: fixtureShareSlug }); + expect((await fixture.readProjectShareState!(scope)).publications[0]).toMatchObject({ status: 'stopped', slug: fixtureShareSlug }); expect(createShareBindingOutbox(db).list()).toEqual([]); } finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); runtime.dispose(); db.close(); await rm(root, { recursive: true, force: true }); } }); +it('S9-R: explicit stopped-link resume returns the original receipt without uploading a second version', async () => { + const root = await mkdtemp(join(tmpdir(), 'od-original-link-resume-')); + const db = new Database(':memory:'); + migratePublicFilePublications(db); migrateCommentRelayOutbox(db); + const store = createSqlitePublicFilePublicationStore(db); + const context: WorkspaceCollabContext = { + workspaceId: 'w', workspaceMemberId: 'owner', workspaceType: 'personal', role: 'owner', + memberStatus: 'active', lifecycleState: 'active', billingState: 'active', planId: null, providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 1, usedSeats: 1 }), permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }), + }; + const scope = { resourceTeamId: 'w', ownerMemberId: 'owner', projectId: 'p', filePath: 'index.html' }; + const runtime = createCollabRuntime({ workspaceContext: { current: async () => context } }); + const commands: string[][] = []; + let uploads = 0; + const fixtureOptions = { commands, failResume: false, failResumeAfterActivation: false, failComplete: false, failStop: false }; + let catalogOwner: string | null = 'owner'; + const fixture = createPublicSharePublishingFixture(db, store, async () => JSON.stringify({ id: `version-${++uploads}`, version: uploads }), undefined, fixtureOptions); + const app = express(); app.use(express.json()); + registerCollabSyncRoutes(app, { collab: runtime, publicFilePublicationStore: store, ...fixture, + verifyWorkspaceRequest: async () => context, + resolveSharedProject: async projectId => catalogOwner ? ({ projectId, ownerMemberId: catalogOwner, sharedAt: new Date(1).toISOString() }) : null, + resolveProjectDir: () => root, + }); + const server = createServer(app); + try { + await writeFile(join(root, 'index.html'), '

Original version

'); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); if (!address || typeof address === 'string') throw new Error('no listener'); + const url = `http://127.0.0.1:${address.port}/api/projects/p/files/index.html/publish-public`; + const early = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'resume' }) }); + expect(early.status).toBe(409); + expect(commands).toEqual([]); + const first = await (await fetch(url, { method: 'POST' })).json(); assertJsonObject(first); + expect(first).toMatchObject({ status: 'published', receipt: { version: 1, versionId: 'version-1', slug: fixtureShareSlug } }); + expect((await fetch(url, { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slug: fixtureShareSlug }) })).status).toBe(200); + expect(store.get(scope)).toBeNull(); + // The saved snapshot, not local content after stop, must be reopened. + await writeFile(join(root, 'index.html'), '

Changed locally, not published

'); + const before = commands.length; + fixtureOptions.failResume = true; + const failed = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'resume' }) }); + expect(failed.status).toBe(502); + expect(store.get(scope)).toBeNull(); + expect(uploads).toBe(1); + expect((await fixture.readProjectShareState!(scope)).publications[0]?.status).toBe('stopped'); + fixtureOptions.failResume = false; + fixtureOptions.failComplete = true; + const uncompensated = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'resume' }) }); + expect(uncompensated.status).toBe(502); + expect(await uncompensated.json()).toMatchObject({ error: 'PUBLIC_SHARE_RESUME_RECORD_UNAVAILABLE' }); + expect(store.get(scope)).toBeNull(); + expect((await fixture.readProjectShareState!(scope)).publications[0]?.status).toBe('stopped'); + fixtureOptions.failStop = true; + const manual = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'resume' }) }); + expect(manual.status).toBe(502); + expect(await manual.json()).toMatchObject({ error: { code: 'PUBLIC_FILE_MANUAL_REVOKE_REQUIRED' } }); + expect(store.get(scope)).toBeNull(); + expect((await fixture.readProjectShareState!(scope)).publications[0]?.status).toBe('active'); + const stopUrl = url.replace('index.html', 'other.html'); + const stopHeaders = { 'content-type': 'application/json' }; + expect((await fetch(stopUrl, { method: 'DELETE', headers: stopHeaders, body: JSON.stringify({ slug: fixtureShareSlug }) })).status).toBe(404); + expect((await fetch(url, { method: 'DELETE', headers: stopHeaders, body: JSON.stringify({ slug: 'different-alias' }) })).status).toBe(404); + catalogOwner = null; + expect((await fetch(url, { method: 'DELETE', headers: stopHeaders, body: JSON.stringify({ slug: fixtureShareSlug }) })).status).toBe(403); + catalogOwner = 'other-member'; + expect((await fetch(url, { method: 'DELETE', headers: stopHeaders, body: JSON.stringify({ slug: fixtureShareSlug }) })).status).toBe(403); + expect((await fixture.readProjectShareState!(scope)).publications[0]?.status).toBe('active'); + catalogOwner = 'owner'; + fixtureOptions.failStop = false; + expect((await fetch(url, { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slug: fixtureShareSlug }) })).status).toBe(200); + expect((await fixture.readProjectShareState!(scope)).publications[0]?.status).toBe('stopped'); + fixtureOptions.failComplete = false; + const resumedResponse = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'resume' }) }); + const resumed = await resumedResponse.json(); assertJsonObject(resumed); + expect(resumedResponse.status).toBe(200); + expect(resumed).toMatchObject({ status: 'published', receipt: first.receipt }); + expect(commands.slice(before).map(args => args.slice(0, 2))).toEqual([ + ['share', 'resume-existing'], ['share', 'stop'], ['share', 'resume-existing'], ['share', 'stop'], + ['share', 'resume-existing'], ['share', 'stop'], ['share', 'stop'], ['share', 'resume-existing'], + ]); + expect(uploads).toBe(1); + expect(store.get(scope)?.slug).toBe(fixtureShareSlug); + const after = commands.length; + expect((await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'resume' }) })).status).toBe(409); + expect(commands).toHaveLength(after); + expect((await fixture.readProjectShareState!(scope)).publications[0]).toMatchObject({ status: 'active', slug: fixtureShareSlug }); + // Simulate a crash after the remote stop was confirmed but before OD + // deleted its local revision. The remote stopped state wins, without + // exposing a dead link as active or re-uploading changed local bytes. + expect((await fetch(url, { method: 'DELETE', headers: stopHeaders, body: JSON.stringify({ slug: fixtureShareSlug }) })).status).toBe(200); + store.set(scope, { url: first.url as string, slug: fixtureShareSlug, fileName: 'index.html' }); + const staleState = await (await fetch(url)).json(); assertJsonObject(staleState); + expect(staleState).toMatchObject({ publication: null, status: 'stopped' }); + fixtureOptions.failResumeAfterActivation = true; + const lostResponse = await fetch(url, { method: 'POST', headers: stopHeaders, body: JSON.stringify({ mode: 'resume' }) }); + expect(lostResponse.status).toBe(502); + expect(await lostResponse.json()).toMatchObject({ error: 'PUBLIC_SHARE_RESUME_UNAVAILABLE' }); + expect((await fixture.readProjectShareState!(scope)).publications[0]?.status).toBe('stopped'); + expect(store.get(scope)).toBeNull(); + fixtureOptions.failStop = true; + const ambiguousResponse = await fetch(url, { method: 'POST', headers: stopHeaders, body: JSON.stringify({ mode: 'resume' }) }); + expect(ambiguousResponse.status).toBe(502); + expect(await ambiguousResponse.json()).toMatchObject({ error: { code: 'PUBLIC_FILE_MANUAL_REVOKE_REQUIRED', + data: { projectId: 'p', slug: fixtureShareSlug, fileName: 'index.html' } } }); + expect((await fixture.readProjectShareState!(scope)).publications[0]?.status).toBe('active'); + expect(store.get(scope)).toBeNull(); + fixtureOptions.failStop = false; + fixtureOptions.failResumeAfterActivation = false; + expect((await fetch(url, { method: 'DELETE', headers: stopHeaders, body: JSON.stringify({ slug: fixtureShareSlug }) })).status).toBe(200); + expect((await fixture.readProjectShareState!(scope)).publications[0]?.status).toBe('stopped'); + const staleResumeResponse = await fetch(url, { method: 'POST', headers: stopHeaders, body: JSON.stringify({ mode: 'resume' }) }); + expect(staleResumeResponse.status).toBe(200); + expect(await staleResumeResponse.json()).toMatchObject({ status: 'published', receipt: first.receipt }); + expect(uploads).toBe(1); + expect(store.get(scope)?.slug).toBe(fixtureShareSlug); + } finally { + server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); + runtime.dispose(); db.close(); await rm(root, { recursive: true, force: true }); + } +}); + it('migrates legacy required URL without losing revision, then retains a no-URL publication', () => { const db = new Database(':memory:'); try { @@ -163,3 +287,58 @@ it('migrates legacy required URL without losing revision, then retains a no-URL expect(store.get(scope)?.url).toBeNull(); expect(store.getRevision(scope)?.slug).toBe(fixtureShareSlug); } finally { db.close(); } }); + +it('repeating the existing publish POST pushes a new version even with unchanged source', async () => { + const root = await mkdtemp(join(tmpdir(), 'od-share-repeat-publish-')); + const db = new Database(':memory:'); + migratePublicFilePublications(db); migrateCommentRelayOutbox(db); + const store = createSqlitePublicFilePublicationStore(db); + const context: WorkspaceCollabContext = { + workspaceId: 'w', workspaceMemberId: 'owner', workspaceType: 'personal', role: 'owner', + memberStatus: 'active', lifecycleState: 'active', billingState: 'active', planId: null, providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 1, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }), + }; + const scope = { resourceTeamId: 'w', ownerMemberId: 'owner', projectId: 'p', filePath: 'index.html' }; + const runtime = createCollabRuntime({ workspaceContext: { current: async () => context } }); + const commands: string[][] = []; + const pushedVersionIds: string[] = []; + const fixture = createPublicSharePublishingFixture(db, store, async args => { + expect(args[0]).toBe('push'); + const id = `version-${pushedVersionIds.length + 1}`; + pushedVersionIds.push(id); + return JSON.stringify({ id, version: pushedVersionIds.length }); + }, undefined, { commands }); + const app = express(); app.use(express.json()); + registerCollabSyncRoutes(app, { collab: runtime, publicFilePublicationStore: store, ...fixture, + verifyWorkspaceRequest: async () => context, + resolveSharedProject: async projectId => ({ projectId, ownerMemberId: 'owner', sharedAt: new Date(1).toISOString() }), + resolveProjectDir: () => root, + }); + const server = createServer(app); + try { + await writeFile(join(root, 'index.html'), '

Unchanged

'); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); if (!address || typeof address === 'string') throw new Error('no listener'); + const url = `http://127.0.0.1:${address.port}/api/projects/p/files/index.html/publish-public`; + const first = await fetch(url, { method: 'POST' }); + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ status: 'published' }); + const firstMutationCommands = commands.filter(args => args[0] === 'resource' || (args[0] === 'share' && args[1] === 'publish')); + expect(firstMutationCommands.map(args => args.slice(0, 2))).toEqual([['resource', 'push'], ['share', 'publish']]); + expect(pushedVersionIds).toEqual(['version-1']); + + const commandsBeforeRepeat = commands.length; + const repeated = await fetch(url, { method: 'POST' }); + expect(repeated.status).toBe(200); + expect(await repeated.json()).toMatchObject({ status: 'published' }); + const repeatedMutations = commands.slice(commandsBeforeRepeat).filter(args => args[0] === 'resource' || (args[0] === 'share' && args[1] === 'publish')); + // Characterize the existing publish POST; a future Update no-op requires its own defined contract. + expect(repeatedMutations.map(args => args.slice(0, 2))).toEqual([['resource', 'push'], ['share', 'publish']]); + expect(pushedVersionIds).toEqual(['version-1', 'version-2']); + expect(store.getRevision(scope)?.token).toBeTruthy(); + } finally { + server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); + runtime.dispose(); db.close(); await rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/daemon/tests/public-share-publishing-fixture.ts b/apps/daemon/tests/public-share-publishing-fixture.ts index fc7f6482fb..a7086f083b 100644 --- a/apps/daemon/tests/public-share-publishing-fixture.ts +++ b/apps/daemon/tests/public-share-publishing-fixture.ts @@ -5,6 +5,7 @@ import { createShareBindingOutbox } from '../src/collab/share-binding-outbox.js' import { createSharePublicationCompletion } from '../src/collab/share-publication-completion.js'; import { createPublicFilePublicationRecorder } from '../src/collab/public-file-publication-recording.js'; import { resolvePublicShareViewerUrl } from '../src/collab/public-share-viewer-url.js'; +import { publicFileResourceIdFor } from '../src/collab/public-file-resource-id.js'; import type { PublicFilePublicationStore } from '../src/collab/public-file-publication-store.js'; import type { RegisterCollabSyncRoutesDeps } from '../src/routes/collab-sync.js'; @@ -17,18 +18,22 @@ export function createPublicSharePublishingFixture( store: PublicFilePublicationStore, resource: (args: string[], workspace: string) => Promise, enqueue: Parameters[2] = () => ({ enqueued: 0, skippedInbound: 0 }), - options: { env?: NodeJS.ProcessEnv; configuredEnv?: Record; pending?: boolean; failUpload?: boolean; failStop?: boolean; failResume?: boolean; commands?: string[][]; cloud?: FixtureShareCloud } = {}, + options: { env?: NodeJS.ProcessEnv; configuredEnv?: Record; pending?: boolean; failUpload?: boolean; failStop?: boolean; failResume?: boolean; failResumeAfterActivation?: boolean; failComplete?: boolean; beforeStop?: () => Promise; commands?: string[][]; cloud?: FixtureShareCloud } = {}, ): Pick { const outbox = createShareBindingOutbox(db); let ids = 0; const cloud: FixtureShareCloud = options.cloud ?? new Map(); + let lastReceipt: { slug: string; version: number; versionId: string; publishedAt: number; entryPath: string } | null = null; return { readProjectShareState: async scope => { const publications = [...cloud.values()].filter(item => item.projectId === scope.projectId).map(({ projectId: _projectId, ...item }) => item); return { projectId: scope.projectId, bindingExists: publications.length > 0, hasEverShared: publications.length > 0, publications }; }, sharePublishing: { reservations: createShareAliasReservations(db, () => ids++ === 0 ? fixtureShareSlug : randomUUID()), outbox, - complete: createSharePublicationCompletion(db, createPublicFilePublicationRecorder(db, store, enqueue), outbox, true), + complete: input => { + if (options.failComplete) throw new Error('injected publication completion failure'); + return createSharePublicationCompletion(db, createPublicFilePublicationRecorder(db, store, enqueue), outbox, true)(input); + }, prepare: async (scope, slug) => ({ url: resolvePublicShareViewerUrl(scope.projectId, slug, options.env ?? { OD_VELA_WEB_URL: 'https://viewer.example.test/cloud' }, options.configuredEnv), run: async args => { @@ -39,10 +44,21 @@ export function createPublicSharePublishingFixture( } if (args[0] !== 'share') throw new Error('unexpected CLI namespace'); if (args[1] === 'stop') { + await options.beforeStop?.(); if (options.failStop) throw new Error('remote stop unavailable'); cloud.set(scope.projectId + ':' + scope.filePath, { projectId: scope.projectId, sourceFilePath: scope.filePath, slug, status: 'stopped' }); return JSON.stringify({ status: 'stopped', projectId: scope.projectId, slug }); } + if (args[1] === 'resume-existing') { + const prior = cloud.get(scope.projectId + ':' + scope.filePath); + if (options.failResume || !prior || prior.status !== 'stopped' || prior.slug !== args[2] || !lastReceipt) { + throw new Error('stopped original alias unavailable'); + } + cloud.set(scope.projectId + ':' + scope.filePath, { ...prior, status: 'active' }); + if (options.failResumeAfterActivation) throw new Error('resume response lost after alias activation'); + return JSON.stringify({ status: 'active', projectId: scope.projectId, + resourceId: publicFileResourceIdFor(scope), ...lastReceipt }); + } if (args[1] === 'bind' || args[1] === 'resume') { if (args[1] === 'resume' && options.failResume) throw new Error('resume unavailable'); if (args[1] === 'bind' && cloud.get(scope.projectId + ':' + scope.filePath)?.status === 'stopped') throw new Error('SHARE_BINDING_STOPPED'); @@ -52,7 +68,10 @@ export function createPublicSharePublishingFixture( } if (args[1] !== 'publish') throw new Error('unexpected share operation'); const versionId = args[args.indexOf('--version-id') + 1]; - const receipt = { slug, versionId, version: 1, publishedAt: 1, entryPath: args[args.indexOf('--entry-path') + 1] }; + const entryPath = args[args.indexOf('--entry-path') + 1]; + if (!versionId || !entryPath) throw new Error('missing publication identity'); + const receipt = { slug, versionId, version: 1, publishedAt: 1, entryPath }; + lastReceipt = receipt; const pending = options.pending || cloud.get(scope.projectId + ':' + scope.filePath)?.status === 'stopped'; if (!pending) cloud.set(scope.projectId + ':' + scope.filePath, { projectId: scope.projectId, sourceFilePath: scope.filePath, slug, status: 'active' }); return JSON.stringify({ status: pending ? 'binding_pending' : 'published', ...receipt, receipt, snapshot: { versionId }, ...(pending ? { binding: { code: 'SHARE_BINDING_UNAVAILABLE' } } : {}) }); diff --git a/apps/daemon/tests/publish-http-comment-backfill.test.ts b/apps/daemon/tests/publish-http-comment-backfill.test.ts index 788f5820a3..0cd399f4e1 100644 --- a/apps/daemon/tests/publish-http-comment-backfill.test.ts +++ b/apps/daemon/tests/publish-http-comment-backfill.test.ts @@ -1,141 +1,794 @@ -import { expect, it, vi } from 'vitest'; -import express from 'express'; -import { createServer } from 'node:http'; -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { buildWorkspacePermissions, buildWorkspaceSeatSummary, type WorkspaceCollabContext } from '@open-design/contracts'; -import { closeDatabase, openDatabase, insertProject, insertConversation, upsertPreviewComment, getWorkspaceProjectByProjectId } from '../src/db.js'; -import { createCollabRuntime } from '../src/collab/runtime.js'; -import { registerCollabSyncRoutes } from '../src/routes/collab-sync.js'; -import { createPublicFilePublicationRecorder } from '../src/collab/public-file-publication-recording.js'; -import { createSqlitePublicFilePublicationStore, migratePublicFilePublications } from '../src/collab/public-file-publication-store.js'; -import { enqueuePublishedFileComments } from '../src/collab/published-file-comment-backfill.js'; -import { createCommentRelayOutboxStore, commentRelayLocalBindingMatches } from '../src/collab/comment-relay-outbox.js'; -import { createCollabCloudService } from '../src/collab/collab-cloud-service.js'; -import { createVelaCliCollabClient } from '../src/collab/vela-cli-collab-client.js'; -import { commentRelayScope } from '../src/collab/comment-relay-scope.js'; -import { runVelaResourceCommand } from '../src/collab/vela-cli-resource-adapter.js'; -import { readVelaControlApiContext } from '../src/integrations/vela.js'; -import { createPublicSharePublishingFixture, fixtureShareSlug } from './public-share-publishing-fixture.js'; +import { expect, it, vi } from "vitest"; +import express from "express"; +import { createServer } from "node:http"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildWorkspacePermissions, + buildWorkspaceSeatSummary, + type WorkspaceCollabContext, +} from "@open-design/contracts"; +import { + closeDatabase, + openDatabase, + insertProject, + insertConversation, + upsertPreviewComment, + getWorkspaceProjectByProjectId, + getConversation, + getProjectPreviewComment, + getPreviewComment, + listProjectPreviewComments, + listPreviewComments, + deletePreviewComment, + updatePreviewCommentStatus, + updatePreviewCommentAnchor, + reorderPreviewComment, + updateProject, +} from "../src/db.js"; +import { createCollabRuntime } from "../src/collab/runtime.js"; +import { registerCollabSyncRoutes } from "../src/routes/collab-sync.js"; +import { registerProjectCommentRoutes, registerCommentSyncStateRoutes } from "../src/routes/project/comments.js"; +import { createCommentSyncStateService } from "../src/collab/comment-sync-state.js"; +import { createPublicFilePublicationRecorder } from "../src/collab/public-file-publication-recording.js"; +import { + createSqlitePublicFilePublicationStore, + migratePublicFilePublications, +} from "../src/collab/public-file-publication-store.js"; +import { enqueuePublishedFileComments } from "../src/collab/published-file-comment-backfill.js"; +import { + createCommentRelayOutboxStore, + commentRelayLocalBindingMatches, +} from "../src/collab/comment-relay-outbox.js"; +import { createCollabCloudService } from "../src/collab/collab-cloud-service.js"; +import { createPersonalPublishedCommentMutationHandler } from "../src/server.js"; +import { createVelaCliCollabClient } from "../src/collab/vela-cli-collab-client.js"; +import { commentRelayScope } from "../src/collab/comment-relay-scope.js"; +import { runVelaResourceCommand } from "../src/collab/vela-cli-resource-adapter.js"; +import { readVelaControlApiContext } from "../src/integrations/vela.js"; +import { + createPublicSharePublishingFixture, + fixtureShareSlug, +} from "./public-share-publishing-fixture.js"; -vi.mock('../src/collab/vela-cli-resource-adapter.js', async importOriginal => ({ - ...await importOriginal(), runVelaResourceCommand: vi.fn(), +vi.mock( + "../src/collab/vela-cli-resource-adapter.js", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../src/collab/vela-cli-resource-adapter.js") + >()), + runVelaResourceCommand: vi.fn(), + }), +); +vi.mock("../src/integrations/vela.js", () => ({ + readVelaControlApiContext: vi.fn(), })); -vi.mock('../src/integrations/vela.js', () => ({ readVelaControlApiContext: vi.fn() })); -it.each([false, true])('production publish HTTP uses real comment transaction; second enqueue fails=%s', async fail => { - const root = await mkdtemp(join(tmpdir(), 'od-publish-http-backfill-')); - const db = openDatabase(root); - const context: WorkspaceCollabContext = { - workspaceId: 'w', workspaceMemberId: 'owner', workspaceType: 'personal', role: 'owner', - memberStatus: 'active', lifecycleState: 'active', billingState: 'active', planId: null, providerMode: 'platform_credits', - seatSummary: buildWorkspaceSeatSummary({ seatLimit: 1, usedSeats: 1 }), - permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }), - }; - const runtime = createCollabRuntime({ workspaceContext: { current: async () => context } }); - const app = express(); app.use(express.json()); - const server = createServer(app); - try { - await mkdir(join(root, 'pages')); - await writeFile(join(root, 'pages', 'local.html'), '

Published

'); - insertProject(db, { id: 'p', name: 'Project', createdAt: 1, updatedAt: 1 }); - for (const id of ['a', 'b']) insertConversation(db, { id, projectId: 'p', title: id, createdAt: 1, updatedAt: 1 }); - db.prepare(`INSERT INTO workspace_projects(project_id,workspace_id,visibility,resource_state,created_by_workspace_member_id,created_at,updated_at) - VALUES('p','w','personal','active','owner',1,1)`).run(); - for (const [id, conversationId, filePath] of [['first', 'a', 'pages/local.html'], ['second', 'b', 'pages/local.html'], ['private', 'a', 'private.html']]) { - upsertPreviewComment(db, 'p', conversationId!, { id: id!, note: id!, authorMemberId: 'original-author', - target: { filePath: filePath!, elementId: 'hero', selector: 'h1', label: 'Hero', position: { x: 0, y: 0, width: 1, height: 1 } } }); - } - migratePublicFilePublications(db); - const store = createSqlitePublicFilePublicationStore(db); - const queue = createCommentRelayOutboxStore(db); - if (fail) db.exec("CREATE TRIGGER fail_second BEFORE INSERT ON comment_relay_outbox WHEN NEW.comment_id='second' BEGIN SELECT RAISE(ABORT,'injected queue failure'); END"); - vi.mocked(readVelaControlApiContext).mockReturnValue({ profile: 'test', apiUrl: 'https://hub.example.test', controlKey: 'synthetic', user: null, configMtimeMs: null }); - vi.mocked(runVelaResourceCommand).mockReset(); - vi.mocked(runVelaResourceCommand).mockImplementation(async args => JSON.stringify(args[0] === 'snapshot' - ? { slug: 'stable-alias', name: 'local.html', kind: 'project', versionId: 'v1', createdAt: new Date(1).toISOString() } - : { id: 'v1', version: 1 })); - const publicationCommands: string[][] = []; - registerCollabSyncRoutes(app, { - collab: runtime, publicFilePublicationStore: store, - ...createPublicSharePublishingFixture(db, store, runVelaResourceCommand, enqueuePublishedFileComments, { commands: publicationCommands }), - recordPublicFilePublication: createPublicFilePublicationRecorder(db, store, enqueuePublishedFileComments), - verifyWorkspaceRequest: async req => req.get('x-od-workspace-id') === 'w' && req.get('x-od-workspace-member-id') === 'owner' ? context : null, - resolveSharedProject: async projectId => ({ projectId, ownerMemberId: 'owner', sharedAt: new Date(1).toISOString() }), resolveSharedProjectOwner: async () => 'owner', - resolveProjectDir: () => root, - }); - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('HTTP listener unavailable'); - const response = await fetch(`http://127.0.0.1:${address.port}/api/projects/p/files/pages/local.html/publish-public`, { - method: 'POST', headers: { 'x-od-workspace-id': 'w', 'x-od-workspace-member-id': 'owner' }, - }); - const body = await response.json(); - expect(response.status).toBe(fail ? 502 : 200); - expect(db.inTransaction).toBe(false); - const commands = vi.mocked(runVelaResourceCommand).mock.calls; - expect(commands.map(call => call[0][0])).toEqual(['push']); - expect(publicationCommands.map(args => args.slice(0, 2))).toEqual(fail - ? [['resource', 'push'], ['share', 'publish'], ['share', 'stop']] - : [['resource', 'push'], ['share', 'publish']]); - expect(commands.every(call => call[1] === 'w')).toBe(true); - const scope = { resourceTeamId: 'w', ownerMemberId: 'owner', projectId: 'p', filePath: 'pages/local.html' }; - if (fail) { - expect(body).toMatchObject({ error: 'PUBLIC_FILE_PUBLISH_UNAVAILABLE' }); - expect(store.get(scope)).toBeNull(); expect(queue.count()).toBe(0); - expect(db.prepare('SELECT * FROM comment_relay_publication_mappings').all()).toEqual([]); - } else { - expect(body).toMatchObject({ status: 'published', receipt: { slug: fixtureShareSlug } }); - const rows = queue.listDue(Date.now()); - expect(rows.map(row => row.comment.id)).toEqual(['first', 'second']); - for (const row of rows) { - expect(row.comment).toMatchObject({ filePath: scope.filePath, memberId: 'original-author' }); - expect(row.publication).toEqual({ ...store.getRevision(scope), publicFilePath: 'index.html' }); - } - // Discard publisher objects before creating a fresh delivery service. - const revision = store.getRevision(scope); - await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); - runtime.dispose(); closeDatabase(); - const reopened = openDatabase(root); - const recoveredQueue = createCommentRelayOutboxStore(reopened); - const recoveredStore = createSqlitePublicFilePublicationStore(reopened); - expect(recoveredQueue.listDue(Date.now())).toEqual(rows); - expect(recoveredQueue.listDue(Date.now()).every(row => recoveredQueue.isPublicationCurrent!(row))).toBe(true); - let offline = true; - const delivered: unknown[] = []; - const relay = createCollabCloudService({ - client: createVelaCliCollabClient({ run: async (args, workspaceId, options) => { - expect(args).toEqual(['comment', 'push', 'p', '--comment-file', '-']); - expect(workspaceId).toBe('w'); - if (offline) throw new Error('simulated transport offline'); - if (typeof options?.input !== 'string') throw new Error('expected serialized comment on stdin'); - delivered.push(JSON.parse(options.input)); - return JSON.stringify({ seq: delivered.length }); - } }), - commentOutbox: recoveredQueue, listProjectIds: () => [], retryDelayMs: () => 0, - resolveCommentRelayWorkspaceContext: async () => context, - listRemoteProjectRelayBindings: async () => [{ projectId: 'p', ownerMemberId: 'owner' }], - validateCommentRelayProjectBinding: record => commentRelayLocalBindingMatches(record, getWorkspaceProjectByProjectId(reopened, record.projectId)), - commentRelayScope: (projectId, filePath, ctx) => commentRelayScope({ projectId, filePath, context: ctx, - binding: getWorkspaceProjectByProjectId(reopened, projectId), publications: recoveredStore }), - resolveLocalConversationId: () => 'a', mergeComment: () => 'unchanged', - }); - try { - await relay.flushPendingComments(); - expect(delivered).toEqual([]); expect(recoveredQueue.count()).toBe(2); - expect(recoveredStore.getRevision(scope)).toEqual(revision); - offline = false; - await relay.flushPendingComments(); - expect(delivered).toEqual(['first', 'second'].map(id => expect.objectContaining({ - id, note: id, memberId: 'original-author', filePath: 'index.html', - }))); - expect(recoveredQueue.count()).toBe(0); - await relay.flushPendingComments(); expect(delivered).toHaveLength(2); - } finally { relay.dispose(); } - } - } finally { - if (server.listening) await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); - runtime.dispose(); closeDatabase(); await rm(root, { recursive: true, force: true }); vi.clearAllMocks(); - } +it("server-wired mutation callback resolves the production recorder import", () => { + const handler = createPersonalPublishedCommentMutationHandler({} as any); + expect(handler({} as any, null, false)).toBe(true); }); + +it.each([false, true])( + "production publish HTTP uses real comment transaction; second enqueue fails=%s", + async (fail) => { + const root = await mkdtemp(join(tmpdir(), "od-publish-http-backfill-")); + const db = openDatabase(root); + const context: WorkspaceCollabContext = { + workspaceId: "w", + workspaceMemberId: "owner", + workspaceType: "personal", + role: "owner", + memberStatus: "active", + lifecycleState: "active", + billingState: "active", + planId: null, + providerMode: "platform_credits", + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 1, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ + role: "owner", + lifecycleState: "active", + }), + }; + const runtime = createCollabRuntime({ + workspaceContext: { current: async () => context }, + }); + const app = express(); + app.use(express.json()); + const server = createServer(app); + try { + await mkdir(join(root, "pages")); + await writeFile( + join(root, "pages", "local.html"), + '

Published

', + ); + insertProject(db, { + id: "p", + name: "Project", + createdAt: 1, + updatedAt: 1, + }); + for (const id of ["a", "b"]) + insertConversation(db, { + id, + projectId: "p", + title: id, + createdAt: 1, + updatedAt: 1, + }); + db.prepare(`INSERT INTO workspace_projects(project_id,workspace_id,visibility,resource_state,created_by_workspace_member_id,created_at,updated_at) + VALUES('p','w','personal','active','owner',1,1)`).run(); + for (const [id, conversationId, filePath] of [ + ["first", "a", "pages/local.html"], + ["second", "b", "pages/local.html"], + ["private", "a", "private.html"], + ]) { + upsertPreviewComment(db, "p", conversationId!, { + id: id!, + note: id!, + authorMemberId: "original-author", + target: { + filePath: filePath!, + elementId: "hero", + selector: "h1", + label: "Hero", + position: { x: 0, y: 0, width: 1, height: 1 }, + }, + }); + } + migratePublicFilePublications(db); + const store = createSqlitePublicFilePublicationStore(db); + const queue = createCommentRelayOutboxStore(db); + const routeRelay = createCollabCloudService({ + client: {} as any, + commentOutbox: queue, + listProjectIds: () => [], + resolveLocalProjectRelayBinding: (projectId) => { + const binding = getWorkspaceProjectByProjectId(db, projectId); + return binding + ? { + workspaceId: binding.workspaceId, + ownerMemberId: binding.createdByWorkspaceMemberId, + } + : null; + }, + commentRelayScope: (projectId, filePath, relayContext) => + commentRelayScope({ + projectId, + filePath, + context: relayContext, + binding: getWorkspaceProjectByProjectId(db, projectId), + publications: store, + }), + resolveLocalConversationId: () => "a", + mergeComment: () => "unchanged", + }); + if (fail) + db.exec( + "CREATE TRIGGER fail_second BEFORE INSERT ON comment_relay_outbox WHEN NEW.comment_id='second' BEGIN SELECT RAISE(ABORT,'injected queue failure'); END", + ); + vi.mocked(readVelaControlApiContext).mockReturnValue({ + profile: "test", + apiUrl: "https://hub.example.test", + controlKey: "synthetic", + user: null, + configMtimeMs: null, + }); + vi.mocked(runVelaResourceCommand).mockReset(); + vi.mocked(runVelaResourceCommand).mockImplementation(async (args) => + JSON.stringify( + args[0] === "snapshot" + ? { + slug: "stable-alias", + name: "local.html", + kind: "project", + versionId: "v1", + createdAt: new Date(1).toISOString(), + } + : { id: "v1", version: 1 }, + ), + ); + const publicationCommands: string[][] = []; + let markStopStarted!: () => void; + const stopStarted = new Promise((resolve) => { + markStopStarted = resolve; + }); + let releaseStop!: () => void; + let pauseStop = false; + const stopGate = new Promise((resolve) => { + releaseStop = resolve; + }); + registerCollabSyncRoutes(app, { + collab: runtime, + publicFilePublicationStore: store, + ...createPublicSharePublishingFixture( + db, + store, + runVelaResourceCommand, + enqueuePublishedFileComments, + { + commands: publicationCommands, + beforeStop: async () => { + if (pauseStop) { + markStopStarted(); + await stopGate; + } + }, + }, + ), + recordPublicFilePublication: createPublicFilePublicationRecorder( + db, + store, + enqueuePublishedFileComments, + ), + verifyWorkspaceRequest: async (req) => + req.get("x-od-workspace-id") === "w" && + req.get("x-od-workspace-member-id") === "owner" + ? context + : null, + resolveSharedProject: async (projectId) => ({ + projectId, + ownerMemberId: "owner", + sharedAt: new Date(1).toISOString(), + }), + resolveSharedProjectOwner: async () => "owner", + resolveProjectDir: () => root, + }); + registerProjectCommentRoutes(app, { + db, + projectStore: { updateProject, getWorkspaceProjectByProjectId } as any, + conversations: { + getConversation, + getProjectPreviewComment, + listProjectPreviewComments, + listPreviewComments, + getPreviewComment, + deletePreviewComment, + updatePreviewCommentStatus, + updatePreviewCommentAnchor, + reorderPreviewComment, + upsertPreviewComment, + } as any, + resolveWorkspaceContext: async () => ({ ok: true, context }), + resolveProjectOwnerMemberId: async () => "owner", + resolveAuthorMemberId: async () => "owner", + isSharedProject: async () => true, + isCommentRelayEligible: (projectId, filePath, relayContext) => + Boolean( + commentRelayScope({ + projectId, + filePath, + context: relayContext, + binding: getWorkspaceProjectByProjectId(db, projectId), + publications: store, + }), + ), + onCommentUpdated: (comment, relayContext) => + relayContext + ? routeRelay.enqueueComment(comment, relayContext) + : undefined, + onCommentDeleted: (comment, relayContext) => + relayContext + ? routeRelay.enqueueCommentDeletion(comment, relayContext) + : undefined, + onPublishedCommentMutation: + createPersonalPublishedCommentMutationHandler(db), + }); + registerCommentSyncStateRoutes(app, { + db, service: createCommentSyncStateService(db, async () => true), + authorize: async () => ({ ok: true, context }), + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("HTTP listener unavailable"); + const response = await fetch( + `http://127.0.0.1:${address.port}/api/projects/p/files/pages/local.html/publish-public`, + { + method: "POST", + headers: { + "x-od-workspace-id": "w", + "x-od-workspace-member-id": "owner", + }, + }, + ); + const body = (await response.json()) as Record; + expect(response.status).toBe(fail ? 502 : 200); + expect(db.inTransaction).toBe(false); + const commands = vi.mocked(runVelaResourceCommand).mock.calls; + expect(commands.map((call) => call[0][0])).toEqual(["push"]); + expect(publicationCommands.map((args) => args.slice(0, 2))).toEqual( + fail + ? [ + ["resource", "push"], + ["share", "publish"], + ["share", "stop"], + ] + : [ + ["resource", "push"], + ["share", "publish"], + ], + ); + expect(commands.every((call) => call[1] === "w")).toBe(true); + const scope = { + resourceTeamId: "w", + ownerMemberId: "owner", + projectId: "p", + filePath: "pages/local.html", + }; + if (fail) { + expect(body).toMatchObject({ + error: "PUBLIC_FILE_PUBLISH_UNAVAILABLE", + }); + expect(store.get(scope)).toBeNull(); + expect(queue.count()).toBe(0); + expect( + db.prepare("SELECT * FROM comment_relay_publication_mappings").all(), + ).toEqual([]); + } else { + expect(body).toMatchObject({ + status: "published", + receipt: { slug: fixtureShareSlug }, + }); + const originalRows = queue.listDue(Date.now()); + expect(originalRows.map((row) => row.comment.id)).toEqual([ + "first", + "second", + ]); + const remoteComments = new Map(); + const initialDelivered: unknown[] = []; + const initialKeys: string[] = []; + let failFirstPush = true; + const syncUrl = `http://127.0.0.1:${address.port}/api/projects/p/comment-sync-state?filePath=pages%2Flocal.html`; + const readSync = async () => (await fetch(syncUrl, { headers: { "x-od-workspace-id": "w", "x-od-workspace-member-id": "owner" } })).json(); + expect(await readSync()).toMatchObject({ backfill: { state: "pending", filePath: scope.filePath } }); + const preStopRelay = createCollabCloudService({ + client: { + isConfigured: () => true, + listMembers: async () => [], + registerMember: async () => ({ memberId: "owner" }), + pushComment: async ( + _team: string, + _project: string, + comment: any, + key: string, + ) => { + if (failFirstPush) { failFirstPush = false; throw new Error("transient comment relay failure"); } + initialDelivered.push(comment); + initialKeys.push(key); + if (comment.deleted) remoteComments.delete(comment.id); + else remoteComments.set(comment.id, comment); + return { seq: remoteComments.size + 1 }; + }, + } as any, + commentOutbox: queue, + listProjectIds: () => [], + retryDelayMs: () => 0, + resolveCommentRelayWorkspaceContext: async () => context, + listRemoteProjectRelayBindings: async () => [ + { projectId: "p", ownerMemberId: "owner" }, + ], + validateCommentRelayProjectBinding: (record) => + commentRelayLocalBindingMatches( + record, + getWorkspaceProjectByProjectId(db, record.projectId), + ), + commentRelayScope: (projectId, filePath, ctx) => + commentRelayScope({ + projectId, + filePath, + context: ctx, + binding: getWorkspaceProjectByProjectId(db, projectId), + publications: store, + }), + resolveLocalConversationId: () => "a", + mergeComment: () => "unchanged", + }); + await preStopRelay.flushPendingComments(); + // K2: delivery failure is independent of the already committed URL. The + // daemon background drain retries; no second publication is needed. + expect(await readSync()).toMatchObject({ backfill: { state: "failed", retryable: true, reopened: false } }); + expect(store.get(scope)).toMatchObject({ slug: fixtureShareSlug }); + expect(publicationCommands.filter(args => args[0] === "share" && args[1] === "publish")).toHaveLength(1); + await preStopRelay.flushPendingComments(); + expect(await readSync()).toMatchObject({ backfill: { state: "succeeded", retryable: false } }); + expect( + [...remoteComments.values()].map((comment) => comment.id).sort(), + ).toEqual(["first", "second"]); + const commentHeaders = { + "x-od-workspace-id": "w", + "x-od-workspace-member-id": "owner", + authorization: "Bearer owner", + "content-type": "application/json", + }; + const activeResolve = await fetch( + "http://127.0.0.1:" + + address.port + + "/api/projects/p/conversations/b/comments/second", + { + method: "PATCH", + headers: commentHeaders, + body: JSON.stringify({ status: "resolved" }), + }, + ); + expect(activeResolve.status).toBe(200); + await preStopRelay.flushPendingComments(); + expect(remoteComments.get("second")).toMatchObject({ + status: "resolved", + }); + expect( + db + .prepare("SELECT comment_id FROM published_comment_mutations") + .all(), + ).toEqual([]); + preStopRelay.dispose(); + for (const row of originalRows) { + expect(row.comment).toMatchObject({ + filePath: scope.filePath, + memberId: "original-author", + }); + expect(row.publication).toEqual({ + ...store.getRevision(scope), + publicFilePath: "index.html", + }); + } + // K3/K4: stop makes the old revision ineligible. Comments added while + // stopped remain local; explicit no-upload resume requeues ALL of this + // file's comments under the original slug with a fresh local witness. + const shareUrl = `http://127.0.0.1:${address.port}/api/projects/p/files/pages/local.html/publish-public`; + const headers = { + "x-od-workspace-id": "w", + "x-od-workspace-member-id": "owner", + "content-type": "application/json", + }; + pauseStop = true; + const stopRequest = fetch(shareUrl, { + method: "DELETE", + headers, + body: JSON.stringify({ slug: fixtureShareSlug }), + }); + await stopStarted; + // The ordinary outbox row is cancelled when this in-flight stop commits; + // the durable publication-scoped journal must retain the tombstone. + const deleteDuringStop = await fetch( + "http://127.0.0.1:" + + address.port + + "/api/projects/p/conversations/a/comments/first", + { method: "DELETE", headers: commentHeaders }, + ); + expect(deleteDuringStop.status).toBe(200); + releaseStop(); + expect((await stopRequest).status).toBe(200); + expect(store.get(scope)).toBeNull(); + expect( + originalRows.every((row) => !queue.isPublicationCurrent!(row)), + ).toBe(true); + const resolveStopped = await fetch( + "http://127.0.0.1:" + + address.port + + "/api/projects/p/conversations/b/comments/second", + { + method: "PATCH", + headers: commentHeaders, + body: JSON.stringify({ status: "open" }), + }, + ); + expect(resolveStopped.status).toBe(200); + const resolvePrivate = await fetch( + "http://127.0.0.1:" + + address.port + + "/api/projects/p/conversations/a/comments/private", + { + method: "PATCH", + headers: commentHeaders, + body: JSON.stringify({ status: "resolved" }), + }, + ); + expect(resolvePrivate.status).toBe(200); + expect( + db + .prepare( + "SELECT comment_id, file_path AS filePath FROM published_comment_mutations ORDER BY comment_id", + ) + .all(), + ).toEqual([ + { comment_id: "first", filePath: "pages/local.html" }, + { comment_id: "second", filePath: "pages/local.html" }, + ]); + expect(queue.count()).toBe(0); // No stopped mutation is sent or queued against the revoked witness. + upsertPreviewComment(db, "p", "b", { + id: "while-stopped", + note: "while-stopped", + authorMemberId: "original-author", + target: { + filePath: scope.filePath, + elementId: "hero", + selector: "h1", + label: "Hero", + position: { x: 0, y: 0, width: 1, height: 1 }, + }, + }); + const resumed = await fetch(shareUrl, { + method: "POST", + headers, + body: JSON.stringify({ mode: "resume" }), + }); + expect(resumed.status).toBe(200); + expect(await resumed.json()).toMatchObject({ + status: "published", + receipt: body.receipt, + }); + // Only the remotely verified stop→resume path may label this revision reopened. + expect(await readSync()).toMatchObject({ backfill: { state: "pending", reopened: true } }); + expect( + vi + .mocked(runVelaResourceCommand) + .mock.calls.map((call) => call[0][0]), + ).toEqual(["push"]); + expect(publicationCommands.map((args) => args.slice(0, 2))).toEqual([ + ["resource", "push"], + ["share", "publish"], + ["share", "stop"], + ["share", "resume-existing"], + ]); + let rows = queue.listDue(Date.now()); + expect(rows.map((row) => row.comment.id).sort()).toEqual([ + "first", + "second", + "while-stopped", + ]); + expect( + rows.find((row) => row.comment.id === "first")?.comment.deleted, + ).toBe(true); + expect( + rows.find((row) => row.comment.id === "second")?.comment.status, + ).toBe("open"); + let revision = store.getRevision(scope); + expect(revision?.slug).toBe(fixtureShareSlug); + expect(revision?.token).not.toBe(originalRows[0]?.publication?.token); + expect( + rows.every( + (row) => + row.publication?.token === revision?.token && + row.publication?.publicFilePath === "index.html", + ), + ).toBe(true); + const firstResumeKeys = new Map( + rows.map((row) => [row.comment.id, { key: row.eventKey, payload: JSON.stringify(row.comment) }]), + ); + // A second stop before the offline relay succeeds cancels the first + // resumed outbox. The journal must still remember tombstones that no + // longer have a local preview_comments row to reconstruct from. + expect( + db + .prepare( + "SELECT comment_id FROM published_comment_mutations ORDER BY comment_id", + ) + .all(), + ).toEqual([{ comment_id: "first" }, { comment_id: "second" }]); + const secondStop = await fetch(shareUrl, { + method: "DELETE", + headers, + body: JSON.stringify({ slug: fixtureShareSlug }), + }); + expect(secondStop.status).toBe(200); + expect(store.get(scope)).toBeNull(); + expect(queue.count()).toBe(0); + expect( + db + .prepare( + "SELECT comment_id FROM published_comment_mutations ORDER BY comment_id", + ) + .all(), + ).toEqual([{ comment_id: "first" }, { comment_id: "second" }]); + const secondResume = await fetch(shareUrl, { + method: "POST", + headers, + body: JSON.stringify({ mode: "resume" }), + }); + expect(secondResume.status).toBe(200); + expect(await secondResume.json()).toMatchObject({ + status: "published", + receipt: body.receipt, + }); + expect( + vi + .mocked(runVelaResourceCommand) + .mock.calls.map((call) => call[0][0]), + ).toEqual(["push"]); + expect(publicationCommands.map((args) => args.slice(0, 2))).toEqual([ + ["resource", "push"], + ["share", "publish"], + ["share", "stop"], + ["share", "resume-existing"], + ["share", "stop"], + ["share", "resume-existing"], + ]); + rows = queue.listDue(Date.now()); + revision = store.getRevision(scope); + expect(revision?.token).not.toBe(originalRows[0]?.publication?.token); + expect(rows.map((row) => row.comment.id).sort()).toEqual([ + "first", + "second", + "while-stopped", + ]); + expect( + rows.find((row) => row.comment.id === "first")?.comment.deleted, + ).toBe(true); + expect( + rows.find((row) => row.comment.id === "second")?.comment.status, + ).toBe("open"); + expect( + rows.every( + (row) => + row.publication?.token === revision?.token && + row.publication?.publicFilePath === "index.html", + ), + ).toBe(true); + expect(rows.map(row => ({ id: row.comment.id, sameKey: row.eventKey === firstResumeKeys.get(row.comment.id)?.key, samePayload: JSON.stringify(row.comment) === firstResumeKeys.get(row.comment.id)?.payload }))).toEqual(rows.map(row => ({ id: row.comment.id, sameKey: true, samePayload: true }))); + // Discard publisher objects before creating a fresh delivery service. + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + runtime.dispose(); + closeDatabase(); + const reopened = openDatabase(root); + const recoveredQueue = createCommentRelayOutboxStore(reopened); + const recoveredStore = createSqlitePublicFilePublicationStore(reopened); + expect(recoveredQueue.listDue(Date.now())).toEqual(rows); + expect( + recoveredQueue + .listDue(Date.now()) + .every((row) => recoveredQueue.isPublicationCurrent!(row)), + ).toBe(true); + let offline = true; + const delivered: unknown[] = []; + const deliveredKeys: string[] = []; + const seenRemoteKeys = new Map(); + const attempts: string[] = []; + let loseSecondAck = true; + const relay = createCollabCloudService({ + client: createVelaCliCollabClient({ + run: async (args, workspaceId, options) => { + expect(args).toEqual([ + "comment", + "push", + "p", + "--comment-file", + "-", + "--idempotency-key", + expect.stringMatching(/^[a-f0-9-]{36}$/), + "--historical-author-member-id", + "original-author", + ]); + expect(workspaceId).toBe("w"); + if (offline) throw new Error("simulated transport offline"); + if (typeof options?.input !== "string") + throw new Error("expected serialized comment on stdin"); + const comment = JSON.parse(options.input) as any; + const eventKey = args[6]!; + attempts.push(eventKey); + let seq = seenRemoteKeys.get(eventKey); + if (seq === undefined) { + seq = seenRemoteKeys.size + 1; + seenRemoteKeys.set(eventKey, seq); + delivered.push(comment); + deliveredKeys.push(eventKey); + if (comment.deleted) remoteComments.delete(comment.id); + else remoteComments.set(comment.id, comment); + } + if (comment.id === "second" && loseSecondAck) { + loseSecondAck = false; + throw new Error("remote committed but ACK was lost"); + } + return JSON.stringify({ seq }); + }, + }), + commentOutbox: recoveredQueue, + listProjectIds: () => [], + retryDelayMs: () => 0, + resolveCommentRelayWorkspaceContext: async () => context, + listRemoteProjectRelayBindings: async () => [ + { projectId: "p", ownerMemberId: "owner" }, + ], + validateCommentRelayProjectBinding: (record) => + commentRelayLocalBindingMatches( + record, + getWorkspaceProjectByProjectId(reopened, record.projectId), + ), + commentRelayScope: (projectId, filePath, ctx) => + commentRelayScope({ + projectId, + filePath, + context: ctx, + binding: getWorkspaceProjectByProjectId(reopened, projectId), + publications: recoveredStore, + }), + resolveLocalConversationId: () => "a", + mergeComment: () => "unchanged", + }); + try { + await relay.flushPendingComments(); + expect(delivered).toEqual([]); + expect(recoveredQueue.count()).toBe(3); + expect(recoveredStore.getRevision(scope)).toEqual(revision); + offline = false; + await relay.flushPendingComments(); + expect(delivered).toHaveLength(3); + expect(recoveredQueue.listDue(Date.now()).map(row => row.comment.id)).toEqual(["second"]); + await relay.flushPendingComments(); + expect(attempts.filter(key => key === deliveredKeys.find((_, i) => (delivered[i] as { id: string }).id === "second"))).toHaveLength(2); + expect(delivered).toHaveLength(3); // Remote saw one logical event per key despite replay. + expect( + delivered.find((comment: any) => comment.id === "first"), + ).toMatchObject({ + id: "first", + deleted: true, + memberId: "original-author", + filePath: "index.html", + publicationSlug: fixtureShareSlug, + }); + expect( + delivered.find((comment: any) => comment.id === "second"), + ).toMatchObject({ + id: "second", + status: "open", + memberId: "original-author", + filePath: "index.html", + publicationSlug: fixtureShareSlug, + }); + expect( + delivered.find((comment: any) => comment.id === "while-stopped"), + ).toMatchObject({ + id: "while-stopped", + memberId: "original-author", + filePath: "index.html", + }); + expect(remoteComments.has("first")).toBe(false); + expect(remoteComments.get("second")).toMatchObject({ + status: "open", + memberId: "original-author", + filePath: "index.html", + publicationSlug: fixtureShareSlug, + }); + expect(remoteComments.size).toBe(2); + expect(recoveredQueue.count()).toBe(0); + expect( + reopened + .prepare("SELECT comment_id FROM published_comment_mutations") + .all(), + ).toEqual([]); + await relay.flushPendingComments(); + expect(delivered).toHaveLength(3); + if (process.env.OD_K4_CAPTURE_FILE) { + await writeFile( + process.env.OD_K4_CAPTURE_FILE, + JSON.stringify({ + slug: fixtureShareSlug, + projectId: "p", + initial: initialDelivered, + initialKeys, + delivered, + deliveredKeys, + }), + ); + } + } finally { + relay.dispose(); + } + } + } finally { + if (server.listening) + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + runtime.dispose(); + closeDatabase(); + await rm(root, { recursive: true, force: true }); + vi.clearAllMocks(); + } + }, +); diff --git a/apps/daemon/tests/published-comment-backfill-state.test.ts b/apps/daemon/tests/published-comment-backfill-state.test.ts index 8ff2377d71..2c563b7fcb 100644 --- a/apps/daemon/tests/published-comment-backfill-state.test.ts +++ b/apps/daemon/tests/published-comment-backfill-state.test.ts @@ -135,3 +135,35 @@ it('marks queue removal as a safe discard by default, but succeeds only with exp const reopened = openDatabase(root!); expect(readPublishedCommentBackfill(reopened, subject)).toEqual({ state: 'succeeded', filePath: 'a.html', publicationRevision: revision.token, retryable: false }); }); + +it('migrates legacy batches without inventing a first-publication origin and retains a verified reopened revision', () => { + const { db, scope, publications, revision, subject } = setup(); + db.exec(`DROP TABLE IF EXISTS published_comment_backfill_batches; + CREATE TABLE published_comment_backfill_batches ( + workspace_id TEXT NOT NULL, workspace_member_id TEXT NOT NULL, + project_id TEXT NOT NULL, file_path TEXT NOT NULL, publication_revision TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')), + retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)), code TEXT, + PRIMARY KEY (workspace_id, workspace_member_id, project_id, file_path, publication_revision) + );`); + db.prepare(`INSERT INTO published_comment_backfill_batches + (workspace_id, workspace_member_id, project_id, file_path, publication_revision, state, retryable) + VALUES (?, ?, ?, ?, ?, 'failed', 0)`).run( + scope.resourceTeamId, scope.ownerMemberId, scope.projectId, scope.filePath, revision.token, + ); + expect(readPublishedCommentBackfill(db, subject)).toEqual({ + state: 'failed', filePath: scope.filePath, publicationRevision: revision.token, retryable: false, + }); + const columns = db.prepare('PRAGMA table_info(published_comment_backfill_batches)').all() as Array<{ name: string }>; + expect(columns.some(column => column.name === 'reopened')).toBe(true); + publications.set(scope, { slug: revision.slug, url: 'https://example.test/a', fileName: scope.filePath }); + const current = publications.getRevision(scope)!; + db.transaction(() => recordPublishedCommentBackfill(db, { + scope, publicationRevision: current, commentIds: ['a'], reopened: true, + }))(); + expect(readPublishedCommentBackfill(db, subject)).toMatchObject({ + state: 'pending', publicationRevision: current.token, reopened: true, + }); + expect(db.prepare('SELECT reopened FROM published_comment_backfill_batches WHERE publication_revision=?') + .get(revision.token)).toEqual({ reopened: null }); +}); diff --git a/apps/daemon/tests/published-file-comment-backfill.test.ts b/apps/daemon/tests/published-file-comment-backfill.test.ts index adf3fe0b87..54ff469bc5 100644 --- a/apps/daemon/tests/published-file-comment-backfill.test.ts +++ b/apps/daemon/tests/published-file-comment-backfill.test.ts @@ -2,15 +2,19 @@ import { afterEach, expect, it } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { buildWorkspacePermissions, buildWorkspaceSeatSummary, type WorkspaceCollabContext, type CollabCloudComment } from '@open-design/contracts'; +import { createServer } from 'node:http'; +import express from 'express'; +import { buildWorkspacePermissions, buildWorkspaceSeatSummary, type WorkspaceCollabContext, type CollabCloudComment, type CommentSyncState } from '@open-design/contracts'; import { closeDatabase, openDatabase, insertProject, insertConversation, upsertPreviewComment, getWorkspaceProjectByProjectId } from '../src/db.js'; import { createSqlitePublicFilePublicationStore, migratePublicFilePublications } from '../src/collab/public-file-publication-store.js'; import { createCommentRelayOutboxStore, commentRelayLocalBindingMatches } from '../src/collab/comment-relay-outbox.js'; import { enqueuePublishedFileComments } from '../src/collab/published-file-comment-backfill.js'; import { readPublishedCommentBackfill } from '../src/collab/published-comment-backfill-state.js'; +import { createCommentSyncStateService } from '../src/collab/comment-sync-state.js'; +import { registerCommentSyncStateRoutes } from '../src/routes/project/comments.js'; import { createPublicFilePublicationRecorder } from '../src/collab/public-file-publication-recording.js'; import { createShareFileMapping } from '../src/collab/share-file-mapping.js'; -import { sourcePathForCurrentPublication } from '../src/collab/comment-relay-publication-mapping.js'; +import { recordPublishedCommentMutation, sourcePathForCurrentPublication } from '../src/collab/comment-relay-publication-mapping.js'; import { createCollabCloudService } from '../src/collab/collab-cloud-service.js'; import { createVelaCliCollabClient } from '../src/collab/vela-cli-collab-client.js'; import { commentRelayScope } from '../src/collab/comment-relay-scope.js'; @@ -139,6 +143,68 @@ it('includes all conversations of exactly one file, preserves ids/authors and ne }); s.publish(); expect(s.outbox.count()).toBe(3); }); +it('K5 deletion-only resume tracks its stopped tombstone, retryable failure and remote ACK', async () => { + const s = setup(); s.add('only'); s.publish(); + const original = s.outbox.listDue(Date.now())[0]!; + s.outbox.acknowledge(original, 'delivered'); + s.publications.delete(s.scope); + s.db.transaction(() => { + expect(recordPublishedCommentMutation(s.db, s.scope, { ...original.comment, deleted: true, + updatedAt: original.comment.updatedAt + 1 })).toBe(true); + s.db.prepare("DELETE FROM preview_comments WHERE id='only'").run(); + })(); + const resumed = s.publish(); + expect(resumed.enqueued).toBe(1); + expect(s.outbox.listDue(Date.now()).map(row => [row.comment.id, row.comment.deleted])).toEqual([['only', true]]); + const app = express(); const server = createServer(app); + registerCommentSyncStateRoutes(app, { db: s.db, service: createCommentSyncStateService(s.db, async () => true), + authorize: async req => req.get('x-od-workspace-member-id') === 'owner' + ? { ok: true, context: { workspaceId: 'w', workspaceMemberId: 'owner' } as WorkspaceCollabContext } + : { ok: false, status: 403, code: 'DENIED', message: 'denied' } }); + let relay: ReturnType | undefined; + try { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); if (!address || typeof address === 'string') throw new Error('listener failed'); + const statusUrl = `http://127.0.0.1:${address.port}/api/projects/p/comment-sync-state?filePath=pages%2Fwork.html`; + const getStatus = async (): Promise => { + const response = await fetch(statusUrl, { headers: { 'x-od-workspace-member-id': 'owner' } }); + expect(response.status).toBe(200); return await response.json() as CommentSyncState; + }; + expect((await getStatus()).backfill).toMatchObject({ state: 'pending', retryable: false, publicationRevision: s.publications.getRevision(s.scope)!.token }); + const context: WorkspaceCollabContext = { workspaceId: 'w', workspaceType: 'personal', workspaceMemberId: 'owner', + teamId: 'w', role: 'owner', memberStatus: 'active', lifecycleState: 'active', billingState: 'active', planId: null, + providerMode: 'platform_credits', permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }), + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 1, usedSeats: 1 }) }; + let offline = true; + const delivered: CollabCloudComment[] = []; + relay = createCollabCloudService({ + client: { ...createVelaCliCollabClient({ run: async () => { throw new Error('unexpected CLI'); } }), + pushComment: async (_team, _project, comment) => { + if (offline) throw new Error('offline'); + delivered.push(comment); + return { seq: 2 }; + } }, + commentOutbox: s.outbox, listProjectIds: () => [], retryDelayMs: () => 0, + resolveCommentRelayWorkspaceContext: async () => context, + listRemoteProjectRelayBindings: async () => [{ projectId: 'p', ownerMemberId: 'owner' }], + validateCommentRelayProjectBinding: row => commentRelayLocalBindingMatches(row, getWorkspaceProjectByProjectId(s.db, row.projectId)), + commentRelayScope: (projectId, filePath, ctx) => commentRelayScope({ projectId, filePath, context: ctx, + binding: getWorkspaceProjectByProjectId(s.db, projectId), publications: s.publications }), + resolveLocalConversationId: () => 'a-p', mergeComment: () => 'unchanged', + }); + await relay.flushPendingComments(); + expect((await getStatus()).backfill).toMatchObject({ state: 'failed', retryable: true }); + expect(s.outbox.count()).toBe(1); + offline = false; + await relay.flushPendingComments(); + expect(delivered).toMatchObject([{ id: 'only', deleted: true, filePath: 'index.html' }]); + expect(s.outbox.count()).toBe(0); + expect((await getStatus()).backfill).toMatchObject({ state: 'succeeded', retryable: false }); + } finally { + relay?.dispose(); + if (server.listening) await new Promise(resolve => server.close(() => resolve())); + } +}); it('requires publication transaction and exact current witness', () => { const s = setup(); s.add('a'); s.publications.set(s.scope, s.publication); const input = { scope: s.scope, publicationRevision: s.publications.getRevision(s.scope)!, publicFilePath: 'index.html' }; diff --git a/apps/daemon/tests/routes/project-delete-unshares-team-share.test.ts b/apps/daemon/tests/routes/project-delete-unshares-team-share.test.ts index 3c02b247a5..b320120acf 100644 --- a/apps/daemon/tests/routes/project-delete-unshares-team-share.test.ts +++ b/apps/daemon/tests/routes/project-delete-unshares-team-share.test.ts @@ -227,6 +227,36 @@ it('waits for in-flight public mutation before entering project deletion', async expect(order).toEqual(['publish', 'published', 'stop']); }); +it('S14 confirms a personal project only after each active public alias was stopped', async () => { + const projectId = 's14-shared'; + const hub = fakeHub(); + let stopProject!: (id: string) => Promise; + const { baseUrl, db } = await startServer(hub, id => stopProject(id)); + const store = createSqlitePublicFilePublicationStore(db); + const scope = { resourceTeamId: WORKSPACE_ID, ownerMemberId: OWNER_MEMBER_ID, projectId }; + insertProject(db, { id: projectId, name: 'Shared', createdAt: 1, updatedAt: 1 }); + ensureWorkspaceProject(db, { projectId, workspaceId: WORKSPACE_ID, visibility: 'personal', createdByWorkspaceMemberId: OWNER_MEMBER_ID }); + const remote = new Map(); + for (const filePath of ['a.html', 'b.html']) { + store.set({ ...scope, filePath }, { slug: filePath, url: `https://example.test/${filePath}`, fileName: filePath }); + remote.set(filePath, 'active'); + } + stopProject = id => createProjectPublicFileStop(store, async key => ({ + ...key, stop: async () => { + expect(getProject(db, projectId)).toBeTruthy(); + expect(remote.get(key.filePath)).toBe('active'); + remote.set(key.filePath, 'stopped'); + }, + }))({ ...scope, projectId: id }); + const response = await fetch(`${baseUrl}/api/projects/${projectId}`, { method: 'DELETE', headers: ownerHeaders() }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect([...remote.values()]).toEqual(['stopped', 'stopped']); + expect(store.listByProject(scope)).toEqual([]); + expect(getProject(db, projectId)).toBeNull(); + expect(store.listStops()).toEqual([]); +}); + it.each([true, false])('runs public stop before catalog and local deletion, stop fails=%s', async (fails) => { const hub = fakeHub(); let called = false; diff --git a/apps/daemon/tests/routes/workspace-projects.test.ts b/apps/daemon/tests/routes/workspace-projects.test.ts index 0133c8bc10..3a63d52a55 100644 --- a/apps/daemon/tests/routes/workspace-projects.test.ts +++ b/apps/daemon/tests/routes/workspace-projects.test.ts @@ -865,6 +865,71 @@ describe('workspace project routes', () => { expect(othersTeam.projects.map((item) => item.id)).not.toContain(projectId); }); + it('S1-T2: owner team move grants another member read and comment but not file edits, then revokes access', async () => { + const projectId = `workspace-team-scope-flow-${Date.now()}`; + const projectUrl = `${baseUrl}/api/projects/${projectId}`; + const owner = headers('member-team-flow-owner', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'owner', + }); + const member = headers('member-team-flow-reader', { 'x-od-workspace-type': 'team' }); + await createProject(projectId, 'Team scope authorization flow'); + const conversation = await fetch(`${projectUrl}/conversations`, { + method: 'POST', + headers: owner, + body: JSON.stringify({ title: 'Team comment fixture', sessionMode: 'chat' }), + }); + expect(conversation.status).toBe(200); + const { conversation: { id: conversationId } } = await conversation.json() as { conversation: { id: string } }; + const move = (visibility: 'team' | 'personal') => fetch( + `${baseUrl}/api/workspaces/${workspaceId}/projects/${projectId}/move`, + { method: 'POST', headers: owner, body: JSON.stringify({ visibility }) }, + ); + const shared = await move('team'); + expect(shared.status).toBe(200); + await expect(shared.json()).resolves.toMatchObject({ project: { + id: projectId, + visibility: 'team', + createdByWorkspaceMemberId: 'member-team-flow-owner', + } }); + const seededFile = await fetch(`${projectUrl}/files`, { + method: 'POST', + headers: owner, + body: JSON.stringify({ name: 'index.html', content: '

Shared team project

' }), + }); + expect(seededFile.status).toBe(200); + + const memberProject = await fetch(projectUrl, { headers: member }); + expect(memberProject.status).toBe(200); + const memberFiles = await fetch(`${projectUrl}/files`, { headers: member }); + expect(memberFiles.status).toBe(200); + await expect(memberFiles.json()).resolves.toMatchObject({ files: [expect.objectContaining({ name: 'index.html' })] }); + const commentUrl = `${projectUrl}/conversations/${conversationId}/comments`; + const commentPayload = JSON.stringify({ + target: { + filePath: 'index.html', elementId: 'hero', selector: '[data-od-id="hero"]', + label: 'h1.hero', text: 'Shared team project', htmlHint: '

', + position: { x: 0, y: 0, width: 0, height: 0 }, + }, + note: 'A second team member can comment without edit access', + }); + const memberComment = await fetch(commentUrl, { method: 'POST', headers: member, body: commentPayload }); + expect(memberComment.status).toBe(200); + await expect(memberComment.json()).resolves.toMatchObject({ comment: { note: 'A second team member can comment without edit access' } }); + const deniedEdit = await fetch(`${projectUrl}/files`, { + method: 'POST', + headers: member, + body: JSON.stringify({ name: 'blocked.html', content: 'not an editor' }), + }); + expect(deniedEdit.status).toBe(403); + + expect((await move('personal')).status).toBe(200); + const revokedProject = await fetch(projectUrl, { headers: member }); + expect(revokedProject.status).toBe(403); + expect((await fetch(`${projectUrl}/files`, { headers: member })).status).toBe(403); + expect((await fetch(commentUrl, { method: 'POST', headers: member, body: commentPayload })).status).toBe(403); + }); + it('enforces workspace project permissions on direct project and file write routes', async () => { const projectId = `workspace-direct-write-${Date.now()}`; await createProject(projectId, 'Direct write project'); diff --git a/apps/daemon/tests/vela-share-publish-cli.test.ts b/apps/daemon/tests/vela-share-publish-cli.test.ts index bdc1be68ad..ba3dda3dd4 100644 --- a/apps/daemon/tests/vela-share-publish-cli.test.ts +++ b/apps/daemon/tests/vela-share-publish-cli.test.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { publishReservedVelaShareVersion, publishVelaShareVersion } from '../src/collab/vela-share-publish.js'; import { runPinnedVelaCommand } from '../src/collab/vela-pinned-command.js'; -it.skipIf(!process.env.OD_TEST_VELA_BIN)('keeps one reserved alias through failed publish, SQLite restart, update and partial binding', async () => { +it.skipIf(!process.env.OD_TEST_VELA_BIN)('keeps one reserved alias through atomic failure, SQLite restart, update and rejected binding', async () => { const binary = process.env.OD_TEST_VELA_BIN!; const root = await mkdtemp(path.join(tmpdir(), 'od-reserved-publish-')); const cliRoot = path.join(root, 'cli'); @@ -19,14 +19,12 @@ it.skipIf(!process.env.OD_TEST_VELA_BIN)('keeps one reserved alias through faile const body = JSON.parse(Buffer.concat(chunks).toString()) as Record; calls.push({ url: req.url, method: req.method, workspace: req.headers['x-vela-workspace-id'], body }); res.setHeader('content-type', 'application/json'); - if (req.url === '/api/v1/resources/resource/shares') { + if (req.url === '/api/v1/resources/resource/shares/bound') { publishes.push(body); - if (publishes.length === 1) { res.statusCode = 503; res.end('{}'); return; } - res.end(JSON.stringify({ slug: body.slug, version: publishes.length, publishedAt: 1234 + publishes.length, + if (publishes.length !== 2) { res.statusCode = publishes.length === 1 ? 503 : 409; res.end('{"error":"share_binding_stopped"}'); return; } + res.statusCode = 201; + res.end(JSON.stringify({ slug: body.slug, version: 1, publishedAt: 1235, entryPath: body.entryPath, snapshot: { slug: 'immutable-snapshot', versionId: body.versionId } })); - } else if (req.url === '/api/v1/collab/shares') { - res.statusCode = publishes.length === 3 ? 403 : 200; - res.end(JSON.stringify({ projectId: 'project', slug: body.slug, status: 'active' })); } else { res.statusCode = 404; res.end('{}'); } }); try { @@ -41,31 +39,25 @@ it.skipIf(!process.env.OD_TEST_VELA_BIN)('keeps one reserved alias through faile const pending = publishReservedVelaShareVersion({ scope: { resourceTeamId: 'workspace', ownerMemberId: 'owner', projectId: 'project', filePath: 'pages/local.html' }, resourceId: 'resource', entryPath: 'index.html', name: 'Design', versionId: `immutable-${attempt}` }, createShareAliasReservations(db), args => runPinnedVelaCommand({ args, session, dataRoot: cliRoot, workspaceId: 'workspace', configuredEnv: { VELA_BIN: binary } })); - if (attempt === 1) await expect(pending).rejects.toThrow('PUBLIC_SHARE_PUBLISH_FAILED'); + if (attempt !== 2) await expect(pending).rejects.toThrow('PUBLIC_SHARE_PUBLISH_FAILED'); else { const result = await pending; slug ??= result.receipt.slug; - expect(result.receipt).toEqual({ filePath: 'pages/local.html', slug, version: attempt, - versionId: `immutable-${attempt}`, publishedAt: 1234 + attempt, entryPath: 'index.html' }); - expect(result.status).toBe(attempt === 2 ? 'published' : 'binding_pending'); - if (result.status === 'binding_pending') expect(result.binding).toEqual({ retrying: false, code: 'FORBIDDEN' }); + expect(result).toEqual({ status: 'published', receipt: { filePath: 'pages/local.html', slug, version: 1, + versionId: 'immutable-2', publishedAt: 1235, entryPath: 'index.html' } }); } } finally { db.close(); } expect(await readdir(cliRoot)).toEqual([]); } - expect(calls).toHaveLength(5); expect(publishes).toHaveLength(3); + expect(calls).toHaveLength(3); expect(publishes).toHaveLength(3); expect(publishes.map(body => body.slug)).toEqual([slug, slug, slug]); expect(new Set(publishes.map(body => body.sourceKey)).size).toBe(1); expect(publishes.map(body => body.versionId)).toEqual(['immutable-1', 'immutable-2', 'immutable-3']); - let registeredVersion = 2; - for (const call of calls) { + for (const [index, call] of calls.entries()) { expect(call.method).toBe('POST'); expect(call.workspace).toBe('workspace'); - if (call.url === '/api/v1/collab/shares') { - expect(call.body).toEqual({ projectId: 'project', slug, sourceFilePath: 'pages/local.html', - expectedResourceId: 'resource', expectedVersion: registeredVersion, - expectedVersionId: `immutable-${registeredVersion}` }); - registeredVersion += 1; - } + expect(call.url).toBe('/api/v1/resources/resource/shares/bound'); + expect(call.body).toEqual({ projectId: 'project', sourceFilePath: 'pages/local.html', slug, + sourceKey: publishes[0]?.sourceKey, entryPath: 'index.html', name: 'Design', versionId: `immutable-${index + 1}` }); } } finally { server.closeAllConnections(); if (server.listening) await new Promise(resolve => server.close(() => resolve())); @@ -74,7 +66,7 @@ it.skipIf(!process.env.OD_TEST_VELA_BIN)('keeps one reserved alias through faile }); // Explicit source-built executable only; never use a developer's login/CLI. -it.skipIf(!process.env.OD_TEST_VELA_BIN).each([200, 403])('publishes a stable alias with real Go CLI, binding HTTP=%s', async (bindingStatus) => { +it.skipIf(!process.env.OD_TEST_VELA_BIN).each([201, 403])('publishes a stable alias with real Go CLI, atomic HTTP=%s', async (publishStatus) => { const binary = process.env.OD_TEST_VELA_BIN; if (!binary) throw new Error('explicit test CLI required'); const root = await mkdtemp(path.join(tmpdir(), 'od-go-publish-')); @@ -86,10 +78,9 @@ it.skipIf(!process.env.OD_TEST_VELA_BIN).each([200, 403])('publishes a stable al for await (const chunk of req) chunks.push(Buffer.from(chunk)); requests.push({ url: req.url, method: req.method, bearer: req.headers.authorization, workspace: req.headers['x-vela-workspace-id'], body: JSON.parse(Buffer.concat(chunks).toString('utf8')) }); res.setHeader('content-type', 'application/json'); - if (req.url === '/api/v1/resources/resource/shares') res.end(JSON.stringify(receipt)); - else if (req.url === '/api/v1/collab/shares') { - res.statusCode = bindingStatus; - res.end(JSON.stringify(bindingStatus === 200 ? { projectId: 'project', slug: 'stable', status: 'active' } : { error: 'forbidden', message: 'synthetic private diagnostic' })); + if (req.url === '/api/v1/resources/resource/shares/bound') { + res.statusCode = publishStatus; + res.end(JSON.stringify(publishStatus === 201 ? receipt : { error: 'forbidden', message: 'synthetic private diagnostic' })); } else { res.statusCode = 404; res.end('{}'); } }); try { @@ -98,15 +89,13 @@ it.skipIf(!process.env.OD_TEST_VELA_BIN).each([200, 403])('publishes a stable al if (!address || typeof address === 'string') throw new Error('missing listener'); const session = { profile: 'test' as const, apiUrl: `http://127.0.0.1:${address.port}`, controlKey: 'synthetic-key', user: null, configMtimeMs: null }; const pending = publishVelaShareVersion(input, args => runPinnedVelaCommand({ args, session, dataRoot: root, workspaceId: input.workspaceId, configuredEnv: { VELA_BIN: binary } })); - expect(await pending).toEqual({ - status: bindingStatus === 200 ? 'published' : 'binding_pending', + if (publishStatus === 201) expect(await pending).toEqual({ status: 'published', receipt: { filePath: input.filePath, slug: 'stable', version: 2, versionId: input.versionId, publishedAt: 1234, entryPath: 'index.html' }, - ...(bindingStatus === 200 ? {} : { binding: { retrying: false, code: 'FORBIDDEN' } }), }); - // Confirmed content survives binding failure, without repeating either mutation. + else await expect(pending).rejects.toThrow(/^PUBLIC_SHARE_PUBLISH_FAILED$/); + // A failed binding cannot have committed a new alias version: the CLI sends one atomic request. expect(requests).toEqual([ - { url: '/api/v1/resources/resource/shares', method: 'POST', bearer: 'Bearer synthetic-key', workspace: 'workspace', body: { slug: 'stable', sourceKey: 'index.html', entryPath: 'index.html', name: 'Design', versionId: 'immutable-upload' } }, - { url: '/api/v1/collab/shares', method: 'POST', bearer: 'Bearer synthetic-key', workspace: 'workspace', body: { projectId: 'project', slug: 'stable', sourceFilePath: 'pages/local.html', expectedResourceId: 'resource', expectedVersion: 2, expectedVersionId: 'immutable-upload' } }, + { url: '/api/v1/resources/resource/shares/bound', method: 'POST', bearer: 'Bearer synthetic-key', workspace: 'workspace', body: { projectId: 'project', sourceFilePath: 'pages/local.html', slug: 'stable', sourceKey: 'index.html', entryPath: 'index.html', name: 'Design', versionId: 'immutable-upload' } }, ]); expect(await readdir(root)).toEqual([]); } finally { diff --git a/apps/daemon/tests/vela-share-publish.test.ts b/apps/daemon/tests/vela-share-publish.test.ts index 645073067e..00fbd946f3 100644 --- a/apps/daemon/tests/vela-share-publish.test.ts +++ b/apps/daemon/tests/vela-share-publish.test.ts @@ -29,9 +29,9 @@ it('pins receipt validation and return values to the invocation snapshot', async expect(run).toHaveBeenCalledTimes(1); }); -it('preserves confirmed content on partial success and never trusts child retrying or diagnostics', async () => { +it('rejects a legacy binary partial-success receipt instead of claiming atomic publication', async () => { const run = vi.fn().mockResolvedValue(JSON.stringify({ ...receipt, status: 'binding_pending', binding: { code: 'FORBIDDEN', retrying: true, message: 'private' } })); - expect(await publishVelaShareVersion(input, run)).toEqual({ status: 'binding_pending', receipt: confirmed, binding: { retrying: false, code: 'FORBIDDEN' } }); + await expect(publishVelaShareVersion(input, run)).rejects.toThrow('PUBLIC_SHARE_PUBLISH_FAILED'); expect(run).toHaveBeenCalledTimes(1); }); it.each([{ ...receipt, status: undefined }, { ...receipt, status: 'other' }, { ...receipt, receipt: { ...confirmed, versionId: 'wrong' } }])('rejects unconfirmed result shape', async wire => { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 030bc99023..b93731a795 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -66,6 +66,8 @@ import { type ProjectRenameFenceToken, type ProjectNameAuthorityResolution, } from './components/ProjectView'; +import type { ObservedPublicShareLink, ObservedShareUpdateRequest } from './components/share/observed-public-share-link'; +import { SignedOutObservedShare } from './components/share/SignedOutObservedShare'; import { ProjectCreationPendingView } from './components/ProjectCreationPendingView'; import { projectsForWorkspaceChrome } from './runtime/workspace-chrome-projects'; import { AmrArtifactUpgradeGate } from './components/AmrArtifactUpgradeGate'; @@ -1711,6 +1713,17 @@ function AppInner() { // globals effect below reads it; the sync effects live next to the // other AMR plumbing further down. const [amrLoginStatus, setAmrLoginStatus] = useState(null); + // A public URL is a capability. Retain only the last exact active-share GET + // observed under this App's authenticated account, in memory, for S13 copy. + const [observedPublicShareLink, setObservedPublicShareLink] = useState< + (ObservedPublicShareLink & { accountId: string }) | null + >(null); + const [loginUpdateRequest, setLoginUpdateRequest] = useState(null); + const loginUpdateNonceRef = useRef(0); + const onLoginUpdateRequestHandled = useCallback((nonce: number) => { + setLoginUpdateRequest((current) => current?.nonce === nonce ? null : current); + }, []); + // Inline AMR auth can invalidate the caller identity and intentionally tear // down ProjectView before the login poll reports success. Keep only the // exact failed-turn continuation above that authorization lifetime; the @@ -1765,6 +1778,38 @@ function AppInner() { // snapshot updates `agents`, which makes Settings fetch status again and // creates a status -> models -> agents request loop. const amrLoginStatusRef = useRef(null); + const onObservedPublicShareLink = useCallback((share: ObservedPublicShareLink | null) => { + if (share === null) { setObservedPublicShareLink(null); return; } + const account = amrLoginStatusRef.current; + const currentRoute = routeRef.current; + const authority = projectRouteWorkspaceContextRef.current; + if (share.status !== 'active' || !share.url || !share.slug + || !share.authorizationScopeKey.startsWith('workspace:') + || !isAmrSessionAuthenticated(account) || !account?.user?.id + || currentRoute.kind !== 'project' || currentRoute.projectId !== share.projectId + || (currentRoute.fileName !== null && currentRoute.fileName !== share.filePath) + || !authority || authority.workspaceId !== share.workspaceId + || authority.workspaceMemberId !== share.workspaceMemberId) return; + setObservedPublicShareLink({ ...share, accountId: account.user.id }); + }, []); + useEffect(() => { + if (!observedPublicShareLink) return; + if (route.kind !== 'project' || route.projectId !== observedPublicShareLink.projectId + || (route.fileName !== null && route.fileName !== observedPublicShareLink.filePath) + || (isAmrSessionAuthenticated(amrLoginStatus) && amrLoginStatus?.user?.id !== observedPublicShareLink.accountId)) { + setObservedPublicShareLink(null); + } + }, [route, amrLoginStatus, observedPublicShareLink]); + useEffect(() => { + if (!loginUpdateRequest) return; + if (Date.now() > loginUpdateRequest.expiresAt + || route.kind !== 'project' || route.projectId !== loginUpdateRequest.link.projectId + || (route.fileName !== null && route.fileName !== loginUpdateRequest.link.filePath) + || (isAmrSessionAuthenticated(amrLoginStatus) && amrLoginStatus?.user?.id !== loginUpdateRequest.accountId)) { + setLoginUpdateRequest(null); + } + }, [route, amrLoginStatus, loginUpdateRequest]); + const applyAmrLoginStatus = useCallback(( status: VelaLoginStatus, options: { forceModelRefresh?: boolean; restartOnSignIn?: boolean } = {}, @@ -2061,12 +2106,19 @@ function AppInner() { || amrLoginStatus?.sessionState === 'reauth_required' ) ); + // Keep this exact file route only when its public URL was witnessed under + // the authenticated workspace. Project reads still fail closed below; + // the signed-out surface exposes that URL and no mutation authority. + if (observedPublicShareLink && route.kind === 'project' + && route.projectId === observedPublicShareLink.projectId + && route.fileName === observedPublicShareLink.filePath) return; if (!cloudIdentityRejected) return; if (route.kind === 'home' && route.view === 'onboarding') return; navigate({ kind: 'home', view: 'onboarding' }, { replace: true }); }, [ amrLoginStatus, config.agentId, + observedPublicShareLink, config.mode, route, workspaceContextState.failure, @@ -3145,10 +3197,7 @@ function AppInner() { (input.metadata?.promptTemplate?.prompt?.trim() || undefined); const metadata = mergeLinkedDirsIntoMetadata(input.metadata, input.linkedDirs); - const kind = metadata?.kind ?? null; const fidelity = fidelityToTracking(metadata?.fidelity ?? null); - const creationSource: 'blank' | 'template' | 'zip' | 'folder' = - kind === 'template' ? 'template' : 'blank'; let createWorkspaceContext: WorkspaceCollabContext | null = null; let optimisticProjectId: string | null = input.optimisticProjectId ?? null; let result; @@ -5057,7 +5106,7 @@ function AppInner() { }); }, []); - const handleTuckPet = useCallback( + const _handleTuckPet = useCallback( () => handleSetPetEnabled(false), [handleSetPetEnabled], ); @@ -5440,6 +5489,38 @@ function AppInner() { /> ) : null; + const signedOutObservedShare = observedPublicShareLink + && route.kind === 'project' && route.projectId === observedPublicShareLink.projectId + && route.fileName === observedPublicShareLink.filePath + && amrLoginStatus?.loggedIn === false && amrLoginStatus.loginInFlight !== true + && (activeProject + ? activeProject.id === observedPublicShareLink.projectId + && activeProject.workspaceId === observedPublicShareLink.workspaceId + && projectRouteWorkspaceContext.failure !== undefined + && activeProjectWorkspaceContext === null + : routeSurfaceState === 'missing') + ? observedPublicShareLink : null; + const signedOutShareFallback = signedOutObservedShare ? ( +
+ { + if (signedOutObservedShare.freshness !== 'outdated') return; + setLoginUpdateRequest({ + nonce: ++loginUpdateNonceRef.current, accountId: signedOutObservedShare.accountId, + link: signedOutObservedShare, expiresAt: Date.now() + 60_000, + }); + }} + /> +
+ ) : null; + const localObservedShare = observedPublicShareLink + && activeProject?.id === observedPublicShareLink.projectId + && route.kind === 'project' && route.fileName === observedPublicShareLink.filePath + && activeProject.workspaceId === null && activeProjectWorkspaceContext === null + && amrLoginStatus?.loggedIn === false && amrLoginStatus.loginInFlight !== true + ? observedPublicShareLink : null; if (pendingFrame && !pendingCreation?.created) { appMain = pendingFrame; } else if ( @@ -5461,7 +5542,7 @@ function AppInner() { ); } else if (routeSurfaceState !== 'ready') { const canRetry = routeSurfaceState === 'materialization-failed'; - appMain = pendingFrame ?? ( + appMain = pendingFrame ?? signedOutShareFallback ?? (
@@ -5485,13 +5566,14 @@ function AppInner() { ); } else if ( activeProject + && !localObservedShare && projectRouteWorkspaceContext.failure && ( projectRouteWorkspaceContext.failure === 'forbidden' || activeProjectWorkspaceContext === null ) ) { - appMain = pendingFrame ?? ( + appMain = pendingFrame ?? signedOutShareFallback ?? (
@@ -5542,6 +5624,10 @@ function AppInner() { projectAuthorizationKey={ activeProjectAuthorizationKey ?? activeProject.id } + onObservedPublicShareLink={onObservedPublicShareLink} + observedPublicShareLink={signedOutObservedShare ?? localObservedShare} + loginUpdateRequest={loginUpdateRequest} + onLoginUpdateRequestHandled={onLoginUpdateRequestHandled} amrAuthRetryContinuation={amrAuthRetryContinuation} onArmAmrAuthRetryContinuation={armAmrAuthRetryContinuation} onConsumeAmrAuthRetryContinuation={consumeAmrAuthRetryContinuation} @@ -5721,6 +5807,10 @@ function AppInner() { ? activeProject.workspaceId ?? null : undefined } + preserveSignedOutShareRoute={Boolean(observedPublicShareLink + && route.kind === 'project' && route.projectId === observedPublicShareLink.projectId + && route.fileName === observedPublicShareLink.filePath + && amrLoginStatus?.loggedIn === false && amrLoginStatus.loginInFlight !== true)} onboardingCompleted={config.onboardingCompleted === true} identityScopeKey={workspaceTabsIdentityScopeKey} workspaceContext={workspaceContext} diff --git a/apps/web/src/collab/project-shared-status.ts b/apps/web/src/collab/project-shared-status.ts index 6bb611099d..4517f5741b 100644 --- a/apps/web/src/collab/project-shared-status.ts +++ b/apps/web/src/collab/project-shared-status.ts @@ -9,12 +9,40 @@ import type { WorkspaceCollabContext } from '@open-design/contracts'; -import { fetchProjectCollabStatus } from './collab-client'; +import { evictProjectCollabStatusRead, fetchProjectCollabStatus } from './collab-client'; import { fetchTeamProjectsCatalog } from './team-projects-catalog'; +import { workspaceIdentityCacheKey } from './workspace-identity'; -export async function projectIsSharedWithWorkspace( +// One native change event is delivered synchronously to all mounted viewers. +// Share its fresh read without joining a different event's old burst result. +const eventScopeReads = new WeakMap>>(); + +export function projectIsSharedWithWorkspace( projectId: string, workspaceContext: WorkspaceCollabContext | null, + options?: { event?: Event }, +): Promise { + const event = options?.event; + if (!event) return readWorkspaceScope(projectId, workspaceContext, false); + let reads = eventScopeReads.get(event); + if (!reads) { + reads = new Map(); + eventScopeReads.set(event, reads); + } + const key = `${workspaceIdentityCacheKey(workspaceContext ?? undefined)}|${projectId}`; + let read = reads.get(key); + if (!read) { + evictProjectCollabStatusRead(projectId, '', workspaceContext ?? undefined); + read = readWorkspaceScope(projectId, workspaceContext, true); + reads.set(key, read); + } + return read; +} + +async function readWorkspaceScope( + projectId: string, + workspaceContext: WorkspaceCollabContext | null, + fresh: boolean, ): Promise { try { const body = await fetchProjectCollabStatus(projectId, { @@ -29,7 +57,7 @@ export async function projectIsSharedWithWorkspace( } if (!workspaceContext) return false; try { - const projects = await fetchTeamProjectsCatalog({ context: workspaceContext }); + const projects = await fetchTeamProjectsCatalog({ context: workspaceContext, ...(fresh ? { coalesce: false } : {}) }); return projects.some((project) => project.projectId === projectId); } catch { return false; diff --git a/apps/web/src/components/ChatPane.tsx b/apps/web/src/components/ChatPane.tsx index 8e4acb27e3..9cc8d99320 100644 --- a/apps/web/src/components/ChatPane.tsx +++ b/apps/web/src/components/ChatPane.tsx @@ -97,6 +97,7 @@ import { useLiquidGlass } from '../hooks/useLiquidGlass'; import { fetchProjectMediaTasks, projectRawUrl } from '../providers/registry'; import { appendResourceQuery } from '../collab/workspace-identity'; import { useProjectCollabContext } from '../collab/collab-context'; +import { ArtifactPublicationProvider } from './share/ArtifactPublicationContext'; import { takeComposerSeedFor } from '../state/libraryHandoff'; import { formAnswersDisplayBody, @@ -4419,6 +4420,7 @@ export function ChatPane({ )}
) : null} + + {displayError ? ( /* * 报错卡(稿子组件 19)。终于接回产品 —— 之前 `RunErrorCard` 抽出来了 diff --git a/apps/web/src/components/CloudSignInTip.tsx b/apps/web/src/components/CloudSignInTip.tsx index d6b508b3ed..7aab0cf313 100644 --- a/apps/web/src/components/CloudSignInTip.tsx +++ b/apps/web/src/components/CloudSignInTip.tsx @@ -111,7 +111,7 @@ export function RailAccountRecoveryTip() { * link fallback — and on success every workspace surface is nudged to * re-read, which swaps the rail to the signed-in form (unmounting the card). */ -export function CloudSignInTip() { +export function CloudSignInTip({ sharePrompt = false, className, onLoginSuccess, actionLabel }: { sharePrompt?: boolean; className?: string; onLoginSuccess?: () => void; actionLabel?: string } = {}) { const { t } = useI18n(); const [state, setState] = useState('idle'); const [status, setStatus] = useState(null); @@ -171,6 +171,9 @@ export function CloudSignInTip() { } function finishSignedIn() { + // A requested post-login action belongs to the still-mounted caller; + // broadcast refresh may unmount this tip before the caller can record it. + if (mountedRef.current && !cancelledRef.current) onLoginSuccess?.(); notifyAmrLoginStatusChanged(); notifyWorkspaceContextRefresh(); notifyWorkspaceBillingRefresh(); @@ -192,7 +195,7 @@ export function CloudSignInTip() {
- {t('settings.amrLogin')} + {actionLabel ?? t('settings.amrLogin')}
); @@ -201,7 +204,7 @@ export function CloudSignInTip() {
{ if (!signing) void begin(); }} @@ -211,8 +214,8 @@ export function CloudSignInTip() { event.preventDefault(); void begin(); }} - aria-label={t('entry.cloudCalloutTitle')} - data-testid="entry-cloud-signin-tip" + aria-label={actionLabel ?? t(sharePrompt ? 'settings.amrLogin' : 'entry.cloudCalloutTitle')} + data-testid={sharePrompt ? 'share-cloud-signin' : 'entry-cloud-signin-tip'} > {signing ? ( <> @@ -254,6 +257,8 @@ export function CloudSignInTip() { {headBadge}

{t('settings.amrLoginErrorCompact')}

+ ) : sharePrompt ? ( + headBadge ) : ( <>

{t('entry.cloudCalloutBody')}

diff --git a/apps/web/src/components/DesignsTab.tsx b/apps/web/src/components/DesignsTab.tsx index f8b6dc9b06..44a7ec3f31 100644 --- a/apps/web/src/components/DesignsTab.tsx +++ b/apps/web/src/components/DesignsTab.tsx @@ -12,7 +12,7 @@ import { } from "../analytics/events"; import { useT } from "../i18n"; import { useWorkspaceContext } from "../collab/useWorkspaceContext"; -import { workspaceIdentityCacheKey } from "../collab/workspace-identity"; +import { currentWorkspaceAccountGeneration, workspaceIdentityCacheKey } from "../collab/workspace-identity"; import { getProjectCoverSnapshot, projectCoverSnapshotKey, @@ -36,6 +36,9 @@ import { } from "./design-system-project"; import { LiveArtifactBadges } from "./LiveArtifactBadges"; import { Toast } from "./Toast"; +import { ProjectDeleteConfirmDialog } from "./project-actions/ProjectDeleteConfirmDialog"; +import { useProjectDeleteFlow } from "./project-actions/useProjectDeleteFlow"; +import { useBatchProjectShareCount } from "./project-actions/useBatchProjectShareCount"; import { HtmlProjectCoverFrame, coverFromProjectFile, @@ -141,6 +144,7 @@ export function DesignsTab({ const t = useT(); const analytics = useAnalytics(); const { context: workspaceContext, loading: workspaceContextLoading } = useWorkspaceContext(); + const deleteFlow = useProjectDeleteFlow({ onDelete, analyticsPage: "all_projects", workspaceContext }); // P0 page_view page_name=projects — fire once when the tab mounts so // `/projects` landings register even before the user clicks anything. // ref-keyed to survive re-renders that flip parent state without @@ -184,6 +188,8 @@ export function DesignsTab({ } | null>(null); const [confirmPending, setConfirmPending] = useState(false); const [confirmError, setConfirmError] = useState(null); + const [batchDeleteIds, setBatchDeleteIds] = useState([]); + const batchShares = useBatchProjectShareCount(confirmTarget ? batchDeleteIds : [], workspaceContext); const [view, setView] = useState(() => { if (typeof window === "undefined") return "grid"; try { @@ -343,7 +349,9 @@ export function DesignsTab({ useEffect(() => { try { window.localStorage.setItem(DESIGNS_VIEW_STORAGE_KEY, view); - } catch {} + } catch { + // Storage may be disabled; keep the in-memory view selection usable. + } }, [view]); useEffect(() => { @@ -496,13 +504,7 @@ export function DesignsTab({ setRenameInput(""); }; const handleDeleteProject = (project: Project) => { - setConfirmError(null); - setConfirmTarget({ - title: t("designs.deleteTitle"), - message: t("designs.deleteConfirm", { name: project.name }), - confirmLabel: t("designs.menuDelete"), - onConfirm: () => onDelete(project.id), - }); + deleteFlow.request(project); }; const handleDuplicateProject = (project: Project) => { if (!onDuplicate) return; @@ -519,6 +521,7 @@ export function DesignsTab({ const ids = Array.from(selected); if (ids.length === 0) return; setConfirmError(null); + setBatchDeleteIds(ids); setConfirmTarget({ title: t("designs.deleteTitle"), message: t("designs.deleteSelectedConfirm", { n: ids.length }), @@ -573,7 +576,9 @@ export function DesignsTab({ }; const commitConfirmTarget = async () => { - if (!confirmTarget || confirmPending) return; + if (!confirmTarget || confirmPending || (batchDeleteIds.length > 0 && + (batchShares.pending || batchShares.failed || batchShares.count === null || + batchShares.generation !== currentWorkspaceAccountGeneration()))) return; const target = confirmTarget; setConfirmError(null); setConfirmPending(true); @@ -584,6 +589,7 @@ export function DesignsTab({ return; } setConfirmTarget((current) => current === target ? null : current); + setBatchDeleteIds([]); } catch (error) { setConfirmError(error instanceof Error ? error.message : t("ds.actionFailed")); } finally { @@ -1230,6 +1236,18 @@ export function DesignsTab({ ) : null} + {deleteFlow.target ? ( + void deleteFlow.commit()} + /> + ) : null} {confirmTarget ? ( { if (confirmPending) return; setConfirmTarget(null); + setBatchDeleteIds([]); setConfirmError(null); }} closeOnBackdrop={!confirmPending} @@ -1244,6 +1263,11 @@ export function DesignsTab({ > {confirmTarget.title} {confirmTarget.message} + {batchDeleteIds.length > 0 && batchShares.count !== null && batchShares.count > 0 ? ( + {t("designs.deleteActiveShares", { count: batchShares.count })} + ) : null} + {batchDeleteIds.length > 0 && batchShares.pending ?

{t("common.loading")}

: null} + {batchDeleteIds.length > 0 && batchShares.failed ?

{t("ds.actionFailed")}

: null} {confirmError ? (

{confirmError} @@ -1255,6 +1279,7 @@ export function DesignsTab({ disabled={confirmPending} onClick={() => { setConfirmTarget(null); + setBatchDeleteIds([]); setConfirmError(null); }} > @@ -1264,7 +1289,7 @@ export function DesignsTab({ type="button" className="primary danger" autoFocus - disabled={confirmPending} + disabled={confirmPending || (batchDeleteIds.length > 0 && (batchShares.pending || batchShares.failed || batchShares.count === null || batchShares.generation !== currentWorkspaceAccountGeneration()))} onClick={() => void commitConfirmTarget()} > {confirmTarget.confirmLabel} diff --git a/apps/web/src/components/EntryNavRail.tsx b/apps/web/src/components/EntryNavRail.tsx index ed4f72a052..f35da5abbc 100644 --- a/apps/web/src/components/EntryNavRail.tsx +++ b/apps/web/src/components/EntryNavRail.tsx @@ -611,6 +611,7 @@ function RailRecentSection({ ) : null} {pending && !showPublish ? null : ( - + {showPublish && onPublish ? ( ) : null} {pending ? null : needsFormatChoice && onExport ? ( @@ -739,7 +744,7 @@ function ArtifactCard({ {...{ [ARTIFACT_ANCHOR_ATTR]: artifactAnchorId('export', item.name, anchorScope) }} > - {t('chat.artifact.export')} + {showPublish ? {t('chat.artifact.export')} : t('chat.artifact.export')} ) : ( /* diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index 1cae06f105..7af9fe3b9d 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -3,12 +3,16 @@ import { daemonErrorCodeProp, failureDetailProps } from '../analytics/failure-de import { memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type CSSProperties, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; import type { ArtifactExportFormat } from '../runtime/chat/artifact-export'; import { boundedPublishProgress, ShareTab, type SharePublishFailureKey } from './share/ShareTab'; +import type { ObservedPublicShareLink, ObservedShareUpdateRequest } from './share/observed-public-share-link'; + import { useShareScopeKeyboard } from './share/useShareScopeKeyboard'; import { AfterExportShareGuide } from './share/AfterExportShareGuide'; import { CommentSyncBanner } from './share/CommentSyncBanner'; +import { invalidateCommentSyncState } from './share/useCommentSyncState'; import { useAfterExportShareGuide } from './share/useAfterExportShareGuide'; import { useShareGuideAppUserId } from './share/useShareGuideAppUserId'; import { useProjectShareHistory } from './share/useProjectShareHistory'; +import { notifyProjectShareHistoryChanged } from './share/share-publication-events'; import { SharePanelHeader } from './share/SharePanelHeader'; import { ShareMoreMenu } from './share/ShareMoreMenu'; import shareEntryStyles from './share/ShareEntry.module.css'; @@ -40,6 +44,8 @@ import { type ProjectFileVersion, type SocialShareRequest, type SocialShareResponse, + type ShareContentFreshness, + type ShareStatus, type WorkspaceCollabContext, } from '@open-design/contracts'; import { PREVIEW_OBSERVABILITY_HOST_STATE_MESSAGE_TYPE } from '@open-design/contracts/runtime/preview-observability'; @@ -50,6 +56,7 @@ import { } from '@open-design/contracts/runtime/preview-runtime-state'; import { appendResourceQuery, + currentWorkspaceAccountGeneration, workspaceAccountScopedCacheKey, workspaceIdentityCacheKey, workspaceProjectHeaders, @@ -145,6 +152,7 @@ import { fetchProjectPreviewBaseHref, fetchProjectFiles, fetchProjectFilePublicPublication, + fetchProjectFilePublicShareState, fetchProjectFileText, fetchProjectFileTextPreview, uploadProjectFiles, @@ -285,7 +293,6 @@ import { planLostAnchorWriteBacks, provisionalNextPinNumber, resolveCommentAnchor, - selectionKindLabel, targetFromSnapshot, type AnchorWriteBack, type PreviewCommentSnapshot, @@ -884,8 +891,9 @@ function markdownCodeBlockLanguage(content: string): MarkdownCodeLanguage | null async function highlightMarkdownCodeBlocks(html: string): Promise { if (typeof document === 'undefined') return html; - const root = document.createElement('div'); - root.innerHTML = html; + // renderMarkdownToSafeHtml supplies sanitized markup; parse it in an inert + // document rather than assigning HTML into a live DOM element. + const root = new DOMParser().parseFromString(html, 'text/html').body; const blocks = Array.from(root.querySelectorAll(`[${MARKDOWN_CODE_BLOCK_ATTR}]`)); if (blocks.length === 0) return html; const { highlightCode } = await import('../runtime/shiki'); @@ -898,15 +906,15 @@ async function highlightMarkdownCodeBlocks(html: string): Promise { const source = (code.textContent ?? '').replace(/\n$/, ''); const highlighted = await highlightCode(source, language.lang); if (!highlighted) return; - const wrapper = document.createElement('div'); - wrapper.innerHTML = highlighted; - const highlightedPre = wrapper.firstElementChild; + // Shiki's markup is library-generated, but its parser must also remain inert. + const highlightedPre = new DOMParser().parseFromString(highlighted, 'text/html').body.firstElementChild; if (!(highlightedPre instanceof HTMLElement)) return; highlightedPre.classList.add('markdown-shiki'); highlightedPre.setAttribute('data-lang', language.label); code.closest('pre')?.replaceWith(highlightedPre); block.setAttribute(MARKDOWN_CODE_LANGUAGE_ATTR, language.label); changed = true; + return highlightedPre; })); return changed ? root.innerHTML : html; } @@ -1754,6 +1762,11 @@ interface Props { onOpenFileReplacing?: (openName: string, closeName: string) => void; commentPortalId?: string; onCommentModeChange?: (active: boolean) => void; + /** Only a fresh authorized active-share GET may witness a copy-only public link. */ + onObservedPublicShareLink?: (share: ObservedPublicShareLink | null) => void; + observedPublicShareLink?: ObservedPublicShareLink | null; + loginUpdateRequest?: ObservedShareUpdateRequest | null; + onLoginUpdateRequestHandled?: (nonce: number) => void; // Bumped nonce asking this viewer to open its Share/Export menu (chat-side // "Share" next-step action). Only HTML artifacts expose a Share menu. shareRequest?: { nonce: number; anchorId?: string } | null; @@ -1826,6 +1839,10 @@ function FileViewerLoadingSkeleton() { // preview iframes below are the most expensive thing on screen. Relies on // FileWorkspace passing identity-stable props (see the activeFile* memos // there). +type PendingShareUpdateAfterLogin = { + projectId: string; fileName: string; slug: string; authorizationScopeKey: string; expiresAt: number; +}; + export const FileViewer = memo(function FileViewer({ projectId, projectKind, @@ -1847,6 +1864,10 @@ export const FileViewer = memo(function FileViewer({ onOpenFileReplacing, commentPortalId, onCommentModeChange, + onObservedPublicShareLink, + observedPublicShareLink, + loginUpdateRequest, + onLoginUpdateRequestHandled, shareRequest, downloadRequest, slideNavRequest, @@ -1864,6 +1885,13 @@ export const FileViewer = memo(function FileViewer({ manualEditEntryAllowed = true, }: Props) { const t = useT(); + // Survives a temporary HtmlViewer unmount while workspace authority refreshes. + const pendingShareUpdateAfterLoginRef = useRef(null); + if (pendingShareUpdateAfterLoginRef.current + && (pendingShareUpdateAfterLoginRef.current.projectId !== projectId + || pendingShareUpdateAfterLoginRef.current.fileName !== file.name)) { + pendingShareUpdateAfterLoginRef.current = null; + } const projectCollabContext = useProjectCollabContext(); const projectResourceAuthority = projectCollabContext.projectResourceAuthority ?? (projectCollabContext.workspaceContextLoading @@ -1920,6 +1948,11 @@ export const FileViewer = memo(function FileViewer({ projectId={projectId} projectKind={projectKind} file={file} + pendingShareUpdateAfterLoginRef={pendingShareUpdateAfterLoginRef} + onObservedPublicShareLink={onObservedPublicShareLink} + observedPublicShareLink={observedPublicShareLink} + loginUpdateRequest={loginUpdateRequest} + onLoginUpdateRequestHandled={onLoginUpdateRequestHandled} liveHtml={liveHtml} filesRefreshKey={filesRefreshKey} isDeck={rendererMatch.renderer.id === 'deck-html'} @@ -2696,7 +2729,7 @@ function JsonPanel({ value, emptyLabel }: { value: unknown; emptyLabel: string } return

{JSON.stringify(value, null, 2)}
; } -function liveArtifactMetadataPayload(liveArtifact: LiveArtifact): unknown { +function liveArtifactMetadataPayload(liveArtifact: LiveArtifact): Record { return { artifact: { id: liveArtifact.id, @@ -2723,13 +2756,13 @@ function liveArtifactMetadataPayload(liveArtifact: LiveArtifact): unknown { }; } -function liveArtifactProvenancePayload(liveArtifact: LiveArtifact): unknown { +function liveArtifactProvenancePayload(liveArtifact: LiveArtifact): Record { return { documentSource: liveArtifact.document?.sourceJson ?? null, }; } -function liveArtifactRefreshPayload(liveArtifact: LiveArtifact): unknown { +function liveArtifactRefreshPayload(liveArtifact: LiveArtifact): Record { return { refreshStatus: liveArtifact.refreshStatus, lastRefreshedAt: liveArtifact.lastRefreshedAt ?? null, @@ -3845,7 +3878,7 @@ function FileVersionManagerModal({ await runProjectVersionExport(version, onExportPdf); return; } - await runVersionExport(version, async (content, title) => { + await runVersionExport(version, async (_content, title) => { const snapshot = await captureVersionPreviewSnapshot({ full: true }); if (!snapshot) throw new Error(t('fileViewer.exportFailed')); await exportSnapshotAsPdf(snapshot, title); @@ -3853,7 +3886,7 @@ function FileVersionManagerModal({ } async function exportVersionImage(version: ProjectFileVersion, format: ImageExportFormat) { - await runVersionExport(version, async (content, title) => { + await runVersionExport(version, async (_content, title) => { const snapshot = await captureVersionPreviewSnapshot({ full: true }); if (!snapshot) throw new Error(t('fileViewer.exportImageFailed')); const blob = await imageDataUrlToBlob(snapshot.dataUrl, format); @@ -4620,6 +4653,54 @@ function commentDisplayLabel(comment: PreviewComment, t: TranslateFn): string { } } +/** List-only disclosure: measure the rendered three-line clamp, never truncate saved comment data. */ +function CommentListBody({ note, t }: { note: string; t: TranslateFn }) { + const bodyRef = useRef(null); + const bodyId = useId(); + const [expanded, setExpanded] = useState(false); + const [overflows, setOverflows] = useState(false); + + useLayoutEffect(() => { + const body = bodyRef.current; + if (!body) return; + const measure = () => { + // The same clamped layout must be measured even while the body is expanded. + const wasClamped = body.classList.contains('is-clamped'); + if (!wasClamped) body.classList.add('is-clamped'); + const next = body.scrollHeight > body.clientHeight + 1; + if (!wasClamped) body.classList.remove('is-clamped'); + setOverflows(previous => previous === next ? previous : next); + }; + measure(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + } + const observer = new ResizeObserver(measure); + observer.observe(body); + if (body.parentElement) observer.observe(body.parentElement); + return () => observer.disconnect(); + }, [note, expanded]); + + return ( +
+
{note}
+ {overflows ? ( + + ) : null} +
+ ); +} + export function CommentSidePanel({ comments, projectId, @@ -4854,7 +4935,7 @@ export function CommentSidePanel({
- + {sendableCount > 0 ? (
-
{comment.note}
+ {projectId && comment.attachments && comment.attachments.length > 0 ? (
{comment.attachments.map((attachment) => { @@ -6172,8 +6253,6 @@ export function CommentTargetOverlay({ <> {displayMembers.map((member, index) => { const bounds = overlayBoundsFromSnapshot(member, scale, overlayOffset); - const width = Math.round(member.position.width); - const height = Math.round(member.position.height); const overlayWeight = overlayWeights[index] ?? { backgroundOpacity: 0.24, outlineOpacity: 0.72, @@ -6631,6 +6710,13 @@ function ReactComponentViewer({ const [moduleEntries, setModuleEntries] = useState(null); const isModule = (moduleEntries?.length ?? 0) > 0; const viewerOnlyDisabledTitle = t('fileViewer.readonlySharedNoExport'); + // This viewer only renders JSX/TSX sources; public sharing accepts HTML entries. + // Keep the sibling scan for preview routing, but never expose an unsupported + // public-share action for either standalone or referenced React components. + const canShareCurrentFile = false; + const shareDisabledTitle = viewerOnly + ? viewerOnlyDisabledTitle + : t('fileViewer.jsxModuleBody'); useEffect(() => { setSource(null); @@ -6662,6 +6748,7 @@ function ReactComponentViewer({ workspaceContext, }).catch(() => null); if (text != null) htmlSources.set(name, text); + return text; }), ); if (cancelled) return; @@ -6704,9 +6791,13 @@ function ReactComponentViewer({ useEffect(() => { let cancelled = false; - const refreshShareAccess = () => void projectIsSharedWithWorkspace(projectId, workspaceContext).then((shared) => { - if (!cancelled) setShareAccess(shared ? 'workspace' : 'private'); - }); + let refreshSeq = 0; + const refreshShareAccess = (event?: Event) => { + const seq = ++refreshSeq; + void projectIsSharedWithWorkspace(projectId, workspaceContext, event ? { event } : undefined).then((shared) => { + if (!cancelled && seq === refreshSeq) setShareAccess(shared ? 'workspace' : 'private'); + }); + }; refreshShareAccess(); window.addEventListener(TEAM_PROJECTS_CHANGED_EVENT, refreshShareAccess); return () => { @@ -6722,10 +6813,15 @@ function ReactComponentViewer({ }, [shareMenuOpen]); useEffect(() => { - if (!viewerOnly) return; - setShareMenuOpen(false); - setShareAccessMenuOpen(false); - }, [viewerOnly]); + // A Share popover must not outlive the standalone-file classification it + // was opened under. Classification resets to null while a file is checked. + // Export remains available during that check, so only close its menu when + // the active tab is Share. + if (viewerOnly || (unifiedActionTab === 'share' && (moduleEntries === null || isModule))) { + setShareMenuOpen(false); + setShareAccessMenuOpen(false); + } + }, [viewerOnly, unifiedActionTab, moduleEntries, isModule]); useEffect(() => { publicFileIdentityRef.current = { projectId, fileName: file.name }; @@ -6737,7 +6833,7 @@ function ReactComponentViewer({ setPublishLinkFeedback(null); setPublishFailureKey(null); // Off-team the read can only 409; don't spend a request per file open on it. - if (!canPublishPublic) return; + if (!canPublishPublic || !canShareCurrentFile) return; // A readonly viewer's publish surface is disabled outright, and the daemon // answers its probe with a slow fixed 403 (2.1 s in the packaged trace) — // skip it from the already-resolved capability state instead of asking and @@ -6817,8 +6913,8 @@ function ReactComponentViewer({ }, requestId ? { requestId } : undefined); }; - async function publishCurrentFilePublic() { - if (viewerOnly || publishingPublicFile) return; + async function publishCurrentFilePublic(mode?: 'resume') { + if (viewerOnly || !canShareCurrentFile || moduleEntries === null || isModule || publishingPublicFile) return; const requestProjectId = projectId; const requestFileName = file.name; const requestSeq = ++publicFileRequestSeqRef.current; @@ -6834,6 +6930,7 @@ function ReactComponentViewer({ requestFileName, workspaceContext, publishRequestId, + mode, ); firePublishResult({ action: 'publish', @@ -7081,17 +7178,20 @@ function ReactComponentViewer({ } aria-haspopup="menu" aria-expanded={shareMenuOpen && unifiedActionTab === tab} - disabled={viewerOnly} - title={viewerOnly ? viewerOnlyDisabledTitle : undefined} + disabled={viewerOnly || (tab === 'share' && !canShareCurrentFile)} + title={tab === 'share' ? shareDisabledTitle : viewerOnly ? viewerOnlyDisabledTitle : undefined} data-tooltip={ - viewerOnly - ? viewerOnlyDisabledTitle + tab === 'share' && shareDisabledTitle + ? shareDisabledTitle + : viewerOnly + ? viewerOnlyDisabledTitle : tab === 'share' ? t('fileViewer.unifiedShareTab') : t('fileViewer.unifiedExportTab') } data-tooltip-placement="bottom" onClick={() => { + if (tab === 'share' && !canShareCurrentFile) return; setShareMenuOpen((v) => !(v && unifiedActionTab === tab)); setUnifiedActionTab(tab); }} @@ -7257,9 +7357,9 @@ function ReactComponentViewer({ type="button" className="share-menu-item" role="menuitem" - disabled={viewerOnly || publishingPublicFile} + disabled={viewerOnly || !canShareCurrentFile || publishingPublicFile} aria-busy={publishingPublicFile} - title={viewerOnly ? viewerOnlyDisabledTitle : undefined} + title={viewerOnly ? viewerOnlyDisabledTitle : shareDisabledTitle} onClick={() => { void publishCurrentFilePublic(); }} @@ -7507,6 +7607,7 @@ function HtmlViewer({ projectId, projectKind, file, + pendingShareUpdateAfterLoginRef, liveHtml, filesRefreshKey = 0, isDeck, @@ -7523,6 +7624,10 @@ function HtmlViewer({ onOpenFileReplacing, commentPortalId, onCommentModeChange, + onObservedPublicShareLink, + observedPublicShareLink, + loginUpdateRequest, + onLoginUpdateRequestHandled, shareRequest, downloadRequest, slideNavRequest, @@ -7542,6 +7647,11 @@ function HtmlViewer({ projectId: string; projectKind: TrackingProjectKind; file: ProjectFile; + pendingShareUpdateAfterLoginRef: { current: PendingShareUpdateAfterLogin | null }; + onObservedPublicShareLink?: (share: ObservedPublicShareLink | null) => void; + observedPublicShareLink?: ObservedPublicShareLink | null; + loginUpdateRequest?: ObservedShareUpdateRequest | null; + onLoginUpdateRequestHandled?: (nonce: number) => void; liveHtml?: string; filesRefreshKey?: number; isDeck: boolean; @@ -8066,6 +8176,20 @@ function HtmlViewer({ // only renders inside the already-published branch, so a failed FIRST publish // used to leave no trace on screen at all — the button simply returned to idle. const [publishFailureKey, setPublishFailureKey] = useState(null); + const [fileShareFreshness, setFileShareFreshness] = useState('unknown'); + const [fileShareStatus, setFileShareStatus] = useState(null); + const [updateToast, setUpdateToast] = useState<'success' | 'failure' | 'uncertain' | null>(null); + const sharePanelOpen = deployMenuOpen && unifiedActionTab === 'share'; + const sharePanelWasOpenRef = useRef(false); + useEffect(() => { + const wasOpen = sharePanelWasOpenRef.current; + sharePanelWasOpenRef.current = sharePanelOpen; + // A size rejection describes the previous package. Reopening after edits + // starts a fresh manual attempt, not an automatic retry of stale bytes. + if (sharePanelOpen && !wasOpen && !publishingPublicFile + && publishFailureKey === 'fileViewer.publishFileTooLarge') setPublishFailureKey(null); + }, [sharePanelOpen, publishingPublicFile, publishFailureKey]); + const updateInFlightRef = useRef(false); const filePublished = publishedFileUrl.length > 0; // Public links need a signed-in workspace (any type); see canPublishPublicFile. const canPublishPublic = canPublishPublicFile(workspaceContext); @@ -8083,7 +8207,7 @@ function HtmlViewer({ // Template save UX. We surface a transient "Saved" pill in the share // menu so the user gets feedback without a noisy toast layer. const [savingTemplate, setSavingTemplate] = useState(false); - const [templateNote, setTemplateNote] = useState(null); + const [, setTemplateNote] = useState(null); const [templateModalOpen, setTemplateModalOpen] = useState(false); const [templateName, setTemplateName] = useState(''); @@ -8156,9 +8280,13 @@ function HtmlViewer({ useEffect(() => { if (!workspaceActive) return; let cancelled = false; - const refreshShareAccess = () => void projectIsSharedWithWorkspace(projectId, workspaceContext).then((shared) => { - if (!cancelled) setShareAccess(shared ? 'workspace' : 'private'); - }); + let refreshSeq = 0; + const refreshShareAccess = (event?: Event) => { + const seq = ++refreshSeq; + void projectIsSharedWithWorkspace(projectId, workspaceContext, event ? { event } : undefined).then((shared) => { + if (!cancelled && seq === refreshSeq) setShareAccess(shared ? 'workspace' : 'private'); + }); + }; refreshShareAccess(); window.addEventListener(TEAM_PROJECTS_CHANGED_EVENT, refreshShareAccess); return () => { @@ -8217,14 +8345,25 @@ function HtmlViewer({ clearPublicFileProgressTimers(); setPublishProgress(null); invalidatePublicFileCopy(); + const previousIdentity = publicFileIdentityRef.current; + // S13 keeps an already observed link copyable on same-file sign-out; a + // file/account switch still clears it pending authoritative re-read. + const preserveKnownLink = !canPublishPublic && previousIdentity.projectId === projectId + && previousIdentity.fileName === file.name && Boolean(publishedFileUrl); publicFileIdentityRef.current = { projectId, fileName: file.name }; const requestSeq = ++publicFileRequestSeqRef.current; let cancelled = false; - setPublishedFileUrl(''); - setPublishedFileSlug(''); + if (!preserveKnownLink) { + setPublishedFileUrl(''); + setPublishedFileSlug(''); + } setPublishingPublicFile(false); setPublishLinkFeedback(null); setPublishFailureKey(null); + setFileShareFreshness('unknown'); + if (!preserveKnownLink) setFileShareStatus(null); + setUpdateToast(null); + updateInFlightRef.current = false; if (!workspaceActive) return; // Off-team the read can only 409; don't spend a request per file open on it. if (!canPublishPublic) return; @@ -8234,37 +8373,78 @@ function HtmlViewer({ // failing (Batch A §4.4). `viewerOnly` fails closed while ownership is // still unknown, and this effect re-runs when it flips writable. if (viewerOnly) return; - void fetchProjectFilePublicPublication(projectId, file.name, workspaceContext) - .then((publication) => { + void fetchProjectFilePublicShareState(projectId, file.name, workspaceContext) + .then((state) => { const current = publicFileIdentityRef.current; - if ( - cancelled || - publicFileRequestSeqRef.current !== requestSeq || - current.projectId !== projectId || - current.fileName !== file.name - ) { - return; + if (cancelled || publicFileRequestSeqRef.current !== requestSeq + || current.projectId !== projectId || current.fileName !== file.name) return; + setFileShareStatus(state.status ?? null); + setFileShareFreshness(state.freshness ?? 'unknown'); + // Missing local metadata is not proof a live remote link was stopped. + if (state.status === 'stopped') { + onObservedPublicShareLink?.(null); + // A confirmed remote stop outranks a previously known local URL. + // An unavailable read is not a confirmed stop and keeps the link. + setPublishedFileUrl(''); + setPublishedFileSlug(''); + } else if (state.publication) { + setPublishedFileUrl(state.publication.url); + setPublishedFileSlug(state.publication.slug); + if (state.status === 'active' && workspaceContext && sourceAuthorizationScopeKey + && state.publication.url && state.publication.slug) { + onObservedPublicShareLink?.({ status: 'active', projectId, filePath: file.name, + slug: state.publication.slug, url: state.publication.url, + workspaceId: workspaceContext.workspaceId, workspaceMemberId: workspaceContext.workspaceMemberId, + authorizationScopeKey: sourceAuthorizationScopeKey, freshness: state.freshness ?? 'unknown' }); + } } - setPublishedFileUrl(publication?.url ?? ''); - setPublishedFileSlug(publication?.slug ?? ''); }) - .catch(() => undefined); - return () => { - cancelled = true; - }; - // `canPublishPublic` is a dependency, not just a guard: the workspace context - // loads asynchronously, so a team member's first render looks off-team. Without - // it the hydrate would be skipped for good and an already-published file would - // render as unpublished. + .catch(() => { + if (!cancelled && publicFileRequestSeqRef.current === requestSeq) setFileShareFreshness('unknown'); + }); + return () => { cancelled = true; }; + // A workspace context loads asynchronously; re-read when its identity resolves. }, [ - projectId, - file.name, - canPublishPublic, - viewerOnly, - workspaceActive, - sourceAuthorizationScopeKey, + projectId, file.name, canPublishPublic, viewerOnly, workspaceActive, sourceAuthorizationScopeKey, ]); + // Opening Share or changing this file's content refreshes the one state read + // used by card and toolbar. A failed read cannot erase a working old link. + useEffect(() => { + if (!deployMenuOpen || unifiedActionTab !== 'share' || !workspaceActive || !canPublishPublic || viewerOnly + || streaming || publishingPublicFile || updateInFlightRef.current) return; + const requestSeq = ++publicFileRequestSeqRef.current; + setFileShareFreshness('unknown'); + void fetchProjectFilePublicShareState(projectId, file.name, workspaceContext) + .then((state) => { + const current = publicFileIdentityRef.current; + if (requestSeq !== publicFileRequestSeqRef.current + || current.projectId !== projectId || current.fileName !== file.name) return; + setFileShareStatus(state.status ?? null); + setFileShareFreshness(state.freshness ?? 'unknown'); + if (state.status === 'stopped') { + onObservedPublicShareLink?.(null); + // A confirmed remote stop outranks a previously known local URL. + // An unavailable read is not a confirmed stop and keeps the link. + setPublishedFileUrl(''); + setPublishedFileSlug(''); + } else if (state.publication) { + setPublishedFileUrl(state.publication.url); + setPublishedFileSlug(state.publication.slug); + if (state.status === 'active' && workspaceContext && sourceAuthorizationScopeKey + && state.publication.url && state.publication.slug) { + onObservedPublicShareLink?.({ status: 'active', projectId, filePath: file.name, + slug: state.publication.slug, url: state.publication.url, + workspaceId: workspaceContext.workspaceId, workspaceMemberId: workspaceContext.workspaceMemberId, + authorizationScopeKey: sourceAuthorizationScopeKey, freshness: state.freshness ?? 'unknown' }); + } + } + }) + .catch(() => { + if (requestSeq === publicFileRequestSeqRef.current) setFileShareFreshness('unknown'); + }); + }, [deployMenuOpen, unifiedActionTab, sourceSnapshotRefreshKey, liveHtml, streaming, projectId, file.name, workspaceActive, canPublishPublic, viewerOnly, sourceAuthorizationScopeKey]); + // Shared identity fields for the publish-flow events (HtmlViewer copy). // `artifactKindToTracking` only recognises HTML through the renderer id — an HTML // artifact's `file.kind` is `html`, which would degrade to `unknown` — and this @@ -8315,7 +8495,7 @@ function HtmlViewer({ }, requestId ? { requestId } : undefined); }; - async function publishCurrentFilePublic() { + async function publishCurrentFilePublic(mode?: 'resume') { if (streaming || viewerOnly || publishingPublicFile) return; const requestProjectId = projectId; const requestFileName = file.name; @@ -8340,6 +8520,7 @@ function HtmlViewer({ requestFileName, workspaceContext, publishRequestId, + mode, ); firePublishResult({ action: 'publish', @@ -8396,15 +8577,138 @@ function HtmlViewer({ } } } finally { - if (publicFileRequestSeqRef.current === requestSeq) setPublishingPublicFile(false); + if (publicFileRequestSeqRef.current === requestSeq) { + setPublishingPublicFile(false); + invalidateCommentSyncState(requestProjectId, file.path || requestFileName); + notifyProjectShareHistoryChanged(requestProjectId); + } } } + async function updateCurrentFilePublic() { + if (!canPublishPublic || streaming || viewerOnly || !publishedFileSlug || publishingPublicFile + || updateInFlightRef.current || fileShareFreshness !== 'outdated' || fileShareStatus !== 'active') return; + const requestProjectId = projectId; + const requestFileName = file.name; + const requestSeq = ++publicFileRequestSeqRef.current; + updateInFlightRef.current = true; + setPublishingPublicFile(true); + setUpdateToast(null); + const started = performance.now(); + const requestId = analytics.newRequestId(); + try { + const response = await publishProjectFilePublic(requestProjectId, requestFileName, workspaceContext, requestId); + firePublishResult({ + action: 'publish', result: 'success', + publish_duration_ms: Math.round(performance.now() - started), + }, requestId); + const current = publicFileIdentityRef.current; + if (requestSeq !== publicFileRequestSeqRef.current + || current.projectId !== requestProjectId || current.fileName !== requestFileName) return; + setPublishedFileUrl(response.url); + setPublishedFileSlug(response.slug); + setFileShareFreshness('unknown'); + setUpdateToast('success'); + // The source may change again during upload. Only the daemon's COMPLETE + // publish-plan fingerprint comparison can claim the new link is current. + void fetchProjectFilePublicShareState(requestProjectId, requestFileName, workspaceContext) + .then((state) => { + if (requestSeq !== publicFileRequestSeqRef.current) return; + setFileShareStatus(state.status ?? null); + setFileShareFreshness(state.freshness ?? 'unknown'); + if (state.publication) { + setPublishedFileUrl(state.publication.url); + setPublishedFileSlug(state.publication.slug); + } + }) + .catch(() => { + if (requestSeq === publicFileRequestSeqRef.current) setFileShareFreshness('unknown'); + }); + // Updating a live alias NEVER auto-copies. The person may still copy it + // through the existing independent action above. + } catch (error) { + firePublishResult({ + action: 'publish', result: 'failed', error_code: publishErrorCode(error), + publish_duration_ms: Math.round(performance.now() - started), + ...daemonErrorCodeProp(error), ...failureDetailProps(error), + }, requestId); + if (requestSeq === publicFileRequestSeqRef.current) { + // Only push fails before the remote alias can move. A snapshot or + // persistence failure (or an unclassified response) may have already + // advanced it; never promise the old bytes or offer blind retry. + if (failureDetailProps(error).failed_stage === 'push') { + setUpdateToast('failure'); + } else { + setFileShareFreshness('unknown'); + setUpdateToast('uncertain'); + } + } + } finally { + if (requestSeq === publicFileRequestSeqRef.current) { + updateInFlightRef.current = false; + setPublishingPublicFile(false); + invalidateCommentSyncState(requestProjectId, file.path || requestFileName); + notifyProjectShareHistoryChanged(requestProjectId); + } + } + } + + // Only an explicit signed-out update request can queue this one-shot intent. + // An authenticated read must confirm the same member, slug and outdated + // active publication before the update is allowed to run. + useEffect(() => { + const explicit = loginUpdateRequest?.nonce !== handledLoginUpdateNonceRef.current + ? loginUpdateRequest : null; + const finishExplicit = () => { + if (!explicit) return; + handledLoginUpdateNonceRef.current = explicit.nonce; + onLoginUpdateRequestHandled?.(explicit.nonce); + }; + if (explicit && Date.now() > explicit.expiresAt) { finishExplicit(); return; } + // An active viewer for a different file cannot consume the requested link. + if (explicit && (explicit.link.projectId !== projectId || explicit.link.filePath !== file.name)) return; + const pending = pendingShareUpdateAfterLoginRef.current ?? (explicit ? { + projectId: explicit.link.projectId, fileName: explicit.link.filePath, + slug: explicit.link.slug, authorizationScopeKey: explicit.link.authorizationScopeKey, + expiresAt: explicit.expiresAt, + } : null); + if (!pending) return; + if (Date.now() > pending.expiresAt || pending.projectId !== projectId || pending.fileName !== file.name) { + pendingShareUpdateAfterLoginRef.current = null; + finishExplicit(); + return; + } + if (!sourceAuthorizationScopeKey?.startsWith('workspace:')) return; + if (sourceAuthorizationScopeKey !== pending.authorizationScopeKey || viewerOnly || !workspaceActive) { + pendingShareUpdateAfterLoginRef.current = null; + finishExplicit(); + return; + } + if (fileShareStatus === 'stopped' || fileShareFreshness === 'current' + || (explicit && fileShareStatus === 'active' && publishedFileSlug && publishedFileSlug !== pending.slug)) { + pendingShareUpdateAfterLoginRef.current = null; + finishExplicit(); + return; + } + // This status and freshness must come from this newly mounted viewer's + // authorized GET; no saved URL or authentication event alone can publish. + if (!canPublishPublic || fileShareStatus !== 'active' || fileShareFreshness !== 'outdated' + || !publishedFileUrl || publishedFileSlug !== pending.slug || streaming || publishingPublicFile) return; + pendingShareUpdateAfterLoginRef.current = null; + finishExplicit(); + void updateCurrentFilePublic(); + }, [projectId, file.name, sourceAuthorizationScopeKey, viewerOnly, workspaceActive, + canPublishPublic, fileShareStatus, fileShareFreshness, publishedFileUrl, publishedFileSlug, streaming, publishingPublicFile, + loginUpdateRequest, onLoginUpdateRequestHandled]); + async function unpublishCurrentFilePublic() { if (viewerOnly || !publishedFileSlug || publishingPublicFile) return; const requestProjectId = projectId; const requestFileName = file.name; const requestSlug = publishedFileSlug; + const requestAccountScope = workspaceAccountScopedCacheKey(workspaceContext); + const requestAccountGeneration = currentWorkspaceAccountGeneration(); + let confirmedStop: { sourceFilePath: string; accountScope: string; generation: number } | undefined; const requestSeq = ++publicFileRequestSeqRef.current; invalidatePublicFileCopy(); clearPublicFileProgressTimers(); @@ -8437,6 +8741,8 @@ function HtmlViewer({ } setPublishedFileUrl(''); setPublishedFileSlug(''); + onObservedPublicShareLink?.(null); + confirmedStop = { sourceFilePath: requestFileName, accountScope: requestAccountScope, generation: requestAccountGeneration }; } catch (error) { console.warn('[FileViewer] failed to unpublish public file', error); firePublishResult({ @@ -8456,7 +8762,11 @@ function HtmlViewer({ : failureKey); } } finally { - if (publicFileRequestSeqRef.current === requestSeq) setPublishingPublicFile(false); + if (publicFileRequestSeqRef.current === requestSeq) { + setPublishingPublicFile(false); + invalidateCommentSyncState(requestProjectId, file.path || requestFileName); + notifyProjectShareHistoryChanged(requestProjectId, confirmedStop); + } } } @@ -9185,7 +9495,11 @@ function HtmlViewer({ canvasLeft: snapshot.canvasLeft, canvasTop: snapshot.canvasTop, }, '*'); - } catch {} + } catch (error) { + // The host still restores its own scroll when the iframe denies DOM access. + if (error instanceof DOMException && error.name === 'SecurityError') return; + console.warn('Could not restore preview frame scroll', error); + } }; window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { @@ -9270,8 +9584,30 @@ function HtmlViewer({ sourceLoadMode: HtmlSourceLoadMode; }>; } | null>(null); + const handledLoginUpdateNonceRef = useRef(null); + const retainedShareAuthorizationScopeKeyRef = useRef(null); + const retainedShareWasOutdatedRef = useRef(false); const renderedSourceAuthorizationScopeKeyRef = useRef(sourceAuthorizationScopeKey); if (renderedSourceAuthorizationScopeKeyRef.current !== sourceAuthorizationScopeKey) { + const previousScopeKey = renderedSourceAuthorizationScopeKeyRef.current; + // S13: an explicitly resolved local/signed-out state may retain only the + // previously observed ACTIVE public URL for this exact file, for copy. + // Pending/denied authority and every new account still fail closed. + const retainKnownPublicUrl = sourceAuthorizationScopeKey === 'local' + && previousScopeKey?.startsWith('workspace:') + && publicFileIdentityRef.current.projectId === projectId + && publicFileIdentityRef.current.fileName === file.name + && fileShareStatus === 'active' && Boolean(publishedFileUrl); + if (retainKnownPublicUrl) { + retainedShareAuthorizationScopeKeyRef.current = previousScopeKey; + retainedShareWasOutdatedRef.current = fileShareFreshness === 'outdated'; + } else if (sourceAuthorizationScopeKey?.startsWith('workspace:')) { + retainedShareAuthorizationScopeKeyRef.current = null; + retainedShareWasOutdatedRef.current = false; + if (pendingShareUpdateAfterLoginRef.current?.authorizationScopeKey !== sourceAuthorizationScopeKey) { + pendingShareUpdateAfterLoginRef.current = null; + } + } renderedSourceAuthorizationScopeKeyRef.current = sourceAuthorizationScopeKey; // A real Workspace/member authority change is not a passive refresh. Fail // closed before this render commits so no frame can briefly expose source, @@ -9286,8 +9622,10 @@ function HtmlViewer({ lastGoodSourceForRoutingRef.current = null; prevSourceBeforeReloadRef.current = null; publicFileRequestSeqRef.current += 1; - setPublishedFileUrl(''); - setPublishedFileSlug(''); + if (!retainKnownPublicUrl) { + setPublishedFileUrl(''); + setPublishedFileSlug(''); + } setPublishingPublicFile(false); setPublishLinkFeedback(null); setPublishFailureKey(null); @@ -9489,7 +9827,7 @@ function HtmlViewer({ const templateExportStartedRef = useRef(0); const templateExportOriginPromiseRef = useRef | null>(null); // Same one-terminal-result guard as image export: a template session - // (reset in openSaveAsTemplateModal) emits exactly one success/failed/ + // (reset in _openSaveAsTemplateModal) emits exactly one success/failed/ // cancelled, whether it ends in a save or a modal dismiss. const templateExportResolvedRef = useRef(false); const screenshotInFlightRef = useRef(false); @@ -9712,7 +10050,7 @@ function HtmlViewer({ const [deckThumbnailsCollapsed, setDeckThumbnailsCollapsed] = useState(false); const [speakerNotesEditMode, setSpeakerNotesEditMode] = useState(false); const [speakerNotesDraft, setSpeakerNotesDraft] = useState(''); - const [speakerNotesSaving, setSpeakerNotesSaving] = useState(false); + const [_speakerNotesSaving, setSpeakerNotesSaving] = useState(false); const [speakerNotesStatus, setSpeakerNotesStatus] = useState<'saved' | 'error' | null>(null); const speakerNotesTextareaRef = useRef(null); const boardPreviewScaleOptions = localCommentSideDockActive ? { canvasPadding: 0 } : undefined; @@ -9725,6 +10063,7 @@ function HtmlViewer({ * (产品 2026-08-27)—— 菜单本身是同一块,只是换个地方渲染。 */ const [menuAnchorId, setMenuAnchorId] = useState(null); + const toolbarMenuAnchorId = useId(); const [menuOrigin, setMenuOrigin] = useState<'toolbar' | 'artifact-card'>('toolbar'); /* * 稳定身份 —— 这个回调会进 `AnchoredMenuShell` 里那条 effect 的依赖数组。 @@ -14450,7 +14789,8 @@ function HtmlViewer({ // the viewer), so the template captures the whole design, not a single // page. Surfaced here in the Download menu because templates are saved // from the same artifact output surface as files. - function openSaveAsTemplateModal() { + // Reserved for the gated template entry; the current Share menu exposes only deployment providers. + function _openSaveAsTemplateModal() { setDeployMenuOpen(false); // Start the template click→result correlation; the result fires later from // handleSaveAsTemplate once the save actually resolves. @@ -14944,7 +15284,8 @@ function HtmlViewer({ if (nextTool) setBoardTool(nextTool); } - function activateBoardPicker(nextTool: BoardTool) { + // Reserved for the board picker when its toolbar entry is available again. + function _activateBoardPicker(nextTool: BoardTool) { clearBoardComposer(); fireArtifactToolbarClick(nextTool === 'pod' ? 'pods' : 'comment'); setCommentPanelOpen(false); @@ -15548,12 +15889,8 @@ function HtmlViewer({ fireArtifactHeaderClick(sourceLabel); setExportReadyNudge(false); markExportReadyNudgeSeen(projectId, file.name); - /* - * 工具栏这条路**永远开在原地**,所以先把上一次卡片留下的锚点清掉。 - * `menuAnchorId` 只在卡片那条路上被设过,以前没有任何地方清它:于是卡上开过 - * 一次之后再点工具栏,菜单会去找卡上那枚按钮 —— 卡还在就开错地方,卡滚走了 - * 就 `findAnchor` 落空、什么都不画,表现为**点了没反应**。 - */ + // null 选择工具栏按钮自己的锚点;不能保留上一次产物卡的锚点。 + // 两处菜单都 portal 到 body,避免被右侧预览面板裁切。 setMenuAnchorId(null); setMenuOrigin('toolbar'); setDeployMenuOpen((v) => { @@ -17326,6 +17663,7 @@ function HtmlViewer({ className={`chrome-action chrome-action-secondary chrome-action-with-label chrome-action-text-only chrome-action-unified ${shareEntryStyles.toolbar}`} aria-haspopup="menu" aria-expanded={deployMenuOpen && unifiedActionTab === 'share'} + data-artifact-anchor={toolbarMenuAnchorId} aria-label={shareMenuLabel} disabled={viewerOnly || !rawCanShare} title={viewerOnly ? viewerOnlyDisabledTitle : !rawCanShare || streaming ? shareUnavailableHint : undefined} @@ -17373,15 +17711,15 @@ function HtmlViewer({ /* * **同一块菜单,只是可能换个地方开。** * - * `menuAnchorId` 为空 = 工具栏点开的,原地渲染,和搬动之前逐字一致; - * 有值 = 产物卡上那枚胶囊点开的,portal 到 body 贴着那枚按钮开 + * 工具栏和产物卡入口都从按钮锚定并 portal 到 body,避免右侧面板 + * 的裁切/层叠上下文盖住菜单;入口仍共用这一份菜单与状态。 * (产品 2026-08-27:「为啥这个发布弹窗是这样的?? 为啥不直接复用 * 现在那个分享弹窗??」「导出这个样式也不对呢, 为啥不直接复用?」)。 * * 下面两块面板一行都没动 —— 这正是「一份实现」的意思。 */ { void openDeployModal(option.id); }, }))} /> + publication.sourceFilePath === file.name)?.status ?? null} + publicationStatus={fileShareStatus === 'active' || fileShareStatus === 'stopped' ? fileShareStatus : (projectShareHistory?.publications.find(publication => publication.sourceFilePath === file.name)?.status ?? null)} + publicationFreshness={fileShareFreshness} + updateCurrentFilePublic={updateCurrentFilePublic} + canResumeUpdateAfterLogin={Boolean(retainedShareWasOutdatedRef.current && retainedShareAuthorizationScopeKeyRef.current && fileShareStatus === 'active' && publishedFileUrl)} + onUpdateLoginSuccess={() => { + const priorScope = retainedShareAuthorizationScopeKeyRef.current; + if (!priorScope || !retainedShareWasOutdatedRef.current || !publishedFileSlug + || !publishedFileUrl || fileShareStatus !== 'active') return; + pendingShareUpdateAfterLoginRef.current = { projectId, fileName: file.name, + slug: publishedFileSlug, authorizationScopeKey: priorScope, expiresAt: Date.now() + 120_000 }; + }} + projectId={projectId} + filePath={file.name} menuOrigin={menuOrigin} workspaceContext={workspaceContext} t={t} @@ -17905,6 +18256,20 @@ function HtmlViewer({ }} /> ) : null} + {workspaceActive && updateToast ? ( + { void updateCurrentFilePublic(); } : undefined} + onDismiss={() => setUpdateToast(null)} + /> + ) : null} {/* Portaled to so the screenshot/export toast escapes the preview pane's transform + overflow:hidden. */} {workspaceActive && exportToast && !versionModalOpen diff --git a/apps/web/src/components/FileWorkspace.tsx b/apps/web/src/components/FileWorkspace.tsx index 7c5e7804b9..e33a5434ca 100644 --- a/apps/web/src/components/FileWorkspace.tsx +++ b/apps/web/src/components/FileWorkspace.tsx @@ -15,6 +15,7 @@ import { import { Button } from '@open-design/components'; import { createPortal } from 'react-dom'; import type { DesignSystemEditClickProps, TrackingArtifactKind, TrackingProjectKind } from '@open-design/contracts/analytics'; +import type { ObservedPublicShareLink, ObservedShareUpdateRequest } from './share/observed-public-share-link'; import { useAnalytics } from '../analytics/provider'; import { trackFileManagerClick, @@ -39,9 +40,7 @@ import { projectFileUrl, projectRawUrl, applyLibraryAsset, - createProjectFolder, deleteDesignSystemDraft, - deleteProjectFolder, renameProjectFile, startDesignSystemTokenContractRebuildJob, updateDesignSystemDraft, @@ -73,6 +72,7 @@ import { useDesignKit, hostnameOf, type KitColor } from '../runtime/design-kit'; import { useKitModuleUpload } from '../runtime/kit-upload'; import { appendResourceQuery, + currentWorkspaceAccountGeneration, workspaceIdentityCacheKey, } from '../collab/workspace-identity'; import { @@ -85,7 +85,6 @@ import { type AgentEvent, type AgentInfo, type AppConfig, - type ChatAttachment, type ChatCommentAttachment, type Conversation, conversationIdFromSideChatTabId, @@ -137,6 +136,9 @@ import { Icon, type IconName } from './Icon'; import { projectIsSharedWithWorkspace } from '../collab/project-shared-status'; import { FileSyncBadge, type FileSyncBadgeState } from '../collab/FileSyncBadge'; import { Toast } from './Toast'; +import { activeFileSharesBeforeDelete } from './project-actions/activeFileSharesBeforeDelete'; +import { ProjectDeleteConfirmDialog } from './project-actions/ProjectDeleteConfirmDialog'; +import { useProjectShareHistoryState } from './share/useProjectShareHistory'; import { TabLauncherMenu } from './workspace/TabLauncherMenu'; import { buildLauncherActions, type LauncherContext } from './workspace/tab-launcher'; import { SideChatTab, type ActiveConversationChatState } from './workspace/SideChatTab'; @@ -227,6 +229,10 @@ interface Props { options?: { fresh?: boolean }, ) => Promise | FileRefreshResult | void; onManualFileWritten?: (file: ProjectFile) => void; + onObservedPublicShareLink?: (share: ObservedPublicShareLink | null) => void; + observedPublicShareLink?: ObservedPublicShareLink | null; + loginUpdateRequest?: ObservedShareUpdateRequest | null; + onLoginUpdateRequestHandled?: (nonce: number) => void; isDeck: boolean; streaming?: boolean; /** @@ -247,6 +253,8 @@ interface Props { // tabs in one commit — a finished turn's artifacts (OPEND-2588). `name` is // still the one that ends up active, and is opened whether or not the batch // names it. + /** Exact router file still awaiting the async open request on project mount. */ + routeFileName?: string | null; openRequest?: { name: string; nonce: number; openBatch?: readonly string[] } | null; browserOpenRequest?: BrowserOpenRequest | null; // Browser tab whose must stay mounted even while another workspace @@ -1235,7 +1243,7 @@ interface DesignSystemSectionActivity { todoStatus?: TodoItem['status']; } -function formatBrowserTabUrl(url: string): string { +function _formatBrowserTabUrl(url: string): string { if (!url) return ''; try { const parsed = new URL(url); @@ -1325,11 +1333,16 @@ export function FileWorkspace({ filesGeneration, onRefreshFiles, onManualFileWritten, + onObservedPublicShareLink, + observedPublicShareLink, + loginUpdateRequest, + onLoginUpdateRequestHandled, isDeck, streaming, runInFlight = false, commentQueueOnSend = false, commentSendDisabled = false, + routeFileName, openRequest, browserOpenRequest, pinnedBrowserTabId, @@ -1382,7 +1395,6 @@ export function FileWorkspace({ installationId, chatLocale, conversations = [], - activeConversationId = null, onSelectConversation, onDeleteConversation, onRenameConversation, @@ -1394,7 +1406,6 @@ export function FileWorkspace({ onActiveContextChange, onWorkspaceContextsChange, messages = [], - conversationId, fileActionsBefore, headerActions, viewerOnly = false, @@ -1510,7 +1521,7 @@ export function FileWorkspace({ const [launcherOpen, setLauncherOpen] = useState(false); const [projectShareMenuOpen, setProjectShareMenuOpen] = useState(false); const [projectShareAccess, setProjectShareAccess] = useState<'private' | 'workspace'>('private'); - const [projectShareAccessMenuOpen, setProjectShareAccessMenuOpen] = useState(false); + const [, setProjectShareAccessMenuOpen] = useState(false); const [projectShareConfirm, setProjectShareConfirm] = useState<'private' | 'workspace' | null>(null); const [projectShareBusy, setProjectShareBusy] = useState(false); const [pageCreatorOpen, setPageCreatorOpen] = useState(false); @@ -2611,74 +2622,101 @@ export function FileWorkspace({ }; }, [quickSwitcherOpen]); + const fileDeletePendingRef = useRef(false); + async function confirmFileDelete(filePaths: readonly string[], message: string): Promise { + const generation = currentWorkspaceAccountGeneration(); + let activeCount: number | null; + try { + activeCount = await activeFileSharesBeforeDelete(projectId, filePaths, workspaceContext); + } catch { + alert(t('ds.actionFailed')); + return false; + } + if (generation !== currentWorkspaceAccountGeneration()) return false; + const warning = activeCount !== null && activeCount > 0 + ? `\n\n${t('designs.deleteActiveShares', { count: activeCount })}` + : ''; + return confirm(`${message}${warning}`) && generation === currentWorkspaceAccountGeneration(); + } + async function handleDelete(name: string) { - if (viewerOnly) return; // read-only viewer of a team-shared project - if (!confirm(t('workspace.deleteFileConfirm', { name }))) return; - const ok = await deleteProjectFile(projectId, name, workspaceContext); - if (ok) { - await onRefreshFiles(); - const nextTabs = persistedTabs.filter((n) => n !== name); - if (activeTab === name) { - // User is viewing the file being deleted: fall back to another - // open tab (or the Design Files panel if none remain). - const nextActive = nextTabs[nextTabs.length - 1] ?? null; - onTabsStateChange(workspaceTabsState(nextTabs, nextActive)); - setActiveTab(nextActive ?? DESIGN_FILES_TAB); - } else { - // Deletion was triggered from the Design Files panel (or another - // tab). We preserve `activeTab` because the user is viewing a - // different context (Design Files or another tab) and shouldn't - // be navigated away. Only clear the persisted active reference - // when it points at the deleted file so we don't leave a dangling - // pointer behind. - const nextActive = tabsState.active === name ? null : tabsState.active; - onTabsStateChange(workspaceTabsState(nextTabs, nextActive)); + if (viewerOnly || fileDeletePendingRef.current) return; // read-only viewer or pending deletion + fileDeletePendingRef.current = true; + try { + if (!await confirmFileDelete([name], t('workspace.deleteFileConfirm', { name }))) return; + const ok = await deleteProjectFile(projectId, name, workspaceContext); + if (ok) { + await onRefreshFiles(); + const nextTabs = persistedTabs.filter((n) => n !== name); + if (activeTab === name) { + // User is viewing the file being deleted: fall back to another + // open tab (or the Design Files panel if none remain). + const nextActive = nextTabs[nextTabs.length - 1] ?? null; + onTabsStateChange(workspaceTabsState(nextTabs, nextActive)); + setActiveTab(nextActive ?? DESIGN_FILES_TAB); + } else { + // Deletion was triggered from the Design Files panel (or another + // tab). We preserve `activeTab` because the user is viewing a + // different context (Design Files or another tab) and shouldn't + // be navigated away. Only clear the persisted active reference + // when it points at the deleted file so we don't leave a dangling + // pointer behind. + const nextActive = tabsState.active === name ? null : tabsState.active; + onTabsStateChange(workspaceTabsState(nextTabs, nextActive)); + } + setSketches((curr) => { + const next = { ...curr }; + clearSketchAutosave(name); + delete next[name]; + return next; + }); } - setSketches((curr) => { - const next = { ...curr }; - clearSketchAutosave(name); - delete next[name]; - return next; - }); + } finally { + fileDeletePendingRef.current = false; } } async function handleDeleteMany(names: string[]) { - if (viewerOnly) return; // read-only viewer of a team-shared project + if (viewerOnly || fileDeletePendingRef.current) return; // read-only viewer or pending deletion if (names.length === 0) return; - if (!confirm(t('workspace.deleteSelectedFilesConfirm', { n: names.length }))) return; - const deleted: string[] = []; - const failed: string[] = []; - for (const name of names) { - const ok = await deleteProjectFile(projectId, name, workspaceContext); - if (ok) deleted.push(name); - else failed.push(name); - } - if (deleted.length > 0) { - await onRefreshFiles(); - const deletedSet = new Set(deleted); - const nextTabs = persistedTabs.filter((n) => !deletedSet.has(n)); - if (activeTab && deletedSet.has(activeTab)) { - const nextActive = nextTabs[nextTabs.length - 1] ?? null; - onTabsStateChange(workspaceTabsState(nextTabs, nextActive)); - setActiveTab(nextActive ?? DESIGN_FILES_TAB); - } else { - const nextActive = - tabsState.active && deletedSet.has(tabsState.active) ? null : tabsState.active; - onTabsStateChange(workspaceTabsState(nextTabs, nextActive)); + fileDeletePendingRef.current = true; + try { + if (!await confirmFileDelete(names, t('workspace.deleteSelectedFilesConfirm', { n: names.length }))) return; + const deleted: string[] = []; + const failed: string[] = []; + for (const name of names) { + const ok = await deleteProjectFile(projectId, name, workspaceContext); + if (ok) deleted.push(name); + else failed.push(name); } - setSketches((curr) => { - const next = { ...curr }; - for (const name of deleted) { - clearSketchAutosave(name); - sketchSceneRevisionRef.current.delete(name); - delete next[name]; + if (deleted.length > 0) { + await onRefreshFiles(); + const deletedSet = new Set(deleted); + const nextTabs = persistedTabs.filter((n) => !deletedSet.has(n)); + if (activeTab && deletedSet.has(activeTab)) { + const nextActive = nextTabs[nextTabs.length - 1] ?? null; + onTabsStateChange(workspaceTabsState(nextTabs, nextActive)); + setActiveTab(nextActive ?? DESIGN_FILES_TAB); + } else { + const nextActive = + tabsState.active && deletedSet.has(tabsState.active) ? null : tabsState.active; + onTabsStateChange(workspaceTabsState(nextTabs, nextActive)); } - return next; - }); - } - if (failed.length > 0) { - alert(t('workspace.deleteSelectedFilesPartial', { n: failed.length })); + setSketches((curr) => { + const next = { ...curr }; + for (const name of deleted) { + clearSketchAutosave(name); + sketchSceneRevisionRef.current.delete(name); + delete next[name]; + } + return next; + }); + } + if (failed.length > 0) { + alert(t('workspace.deleteSelectedFilesPartial', { n: failed.length })); + } + } finally { + fileDeletePendingRef.current = false; } } @@ -3120,6 +3158,27 @@ export function FileWorkspace({ ? activeFile : null; const activeHtmlViewerFile = activeViewerFile?.kind === 'html' ? activeViewerFile : null; + const observedActiveFileRef = useRef(null); + useEffect(() => { + const identity = activeViewerFile ? `${projectId}:${activeViewerFile.name}` : null; + // A login intent cannot wait on hidden A while B (or Design Files) is + // active and later publish A when the person returns to its retained tab. + if (loginUpdateRequest && ( + loginUpdateRequest.link.projectId !== projectId + || (activeViewerFile && activeViewerFile.name !== loginUpdateRequest.link.filePath) + || (!activeViewerFile && ( + observedActiveFileRef.current !== null + || (filesAuthoritative && !initialMaterializationPending + && activeTab !== loginUpdateRequest.link.filePath + && routeFileName !== loginUpdateRequest.link.filePath + && openRequest?.name !== loginUpdateRequest.link.filePath) + )) + )) onLoginUpdateRequestHandled?.(loginUpdateRequest.nonce); + if (identity === null || observedActiveFileRef.current === identity) return; + observedActiveFileRef.current = identity; + onObservedPublicShareLink?.(null); + }, [activeViewerFile?.name, projectId, onObservedPublicShareLink, loginUpdateRequest, onLoginUpdateRequestHandled, + activeTab, filesAuthoritative, initialMaterializationPending, openRequest?.name, routeFileName]); const htmlViewerFileSnapshotsRef = useRef<{ projectId: string; files: Map; @@ -3451,6 +3510,11 @@ export function FileWorkspace({ } onFileSaved={refreshFilesWithoutResult} onFileWritten={onManualFileWritten} + onObservedPublicShareLink={onObservedPublicShareLink} + observedPublicShareLink={observedPublicShareLink} + loginUpdateRequest={workspaceActive && loginUpdateRequest?.link.filePath === file.name + && loginUpdateRequest.link.projectId === projectId ? loginUpdateRequest : null} + onLoginUpdateRequestHandled={onLoginUpdateRequestHandled} onOpenFileReplacing={stableOpenFileReplacing} commentPortalId={workspaceActive ? commentPortalId : undefined} onCommentModeChange={workspaceActive ? onCommentModeChange : undefined} @@ -3870,9 +3934,13 @@ export function FileWorkspace({ useEffect(() => { let cancelled = false; - const refreshShareAccess = () => void projectIsSharedWithWorkspace(projectId, workspaceContext).then((shared) => { - if (!cancelled) setProjectShareAccess(shared ? 'workspace' : 'private'); - }); + let refreshSeq = 0; + const refreshShareAccess = (event?: Event) => { + const seq = ++refreshSeq; + void projectIsSharedWithWorkspace(projectId, workspaceContext, event ? { event } : undefined).then((shared) => { + if (!cancelled && seq === refreshSeq) setProjectShareAccess(shared ? 'workspace' : 'private'); + }); + }; refreshShareAccess(); window.addEventListener(TEAM_PROJECTS_CHANGED_EVENT, refreshShareAccess); return () => { @@ -3927,7 +3995,7 @@ export function FileWorkspace({ // Crossing the team-space boundary routes through the shared 转入/移出 // 团队空间 confirmation (same dialog + 不再提示 skip key as the project // grid) instead of silently moving the project. - function setProjectWorkspaceShareAccess(nextAccess: 'private' | 'workspace') { + function _setProjectWorkspaceShareAccess(nextAccess: 'private' | 'workspace') { setProjectShareAccessMenuOpen(false); if (nextAccess === projectShareAccess || projectShareBusy || viewerOnly) return; if (moveConfirmSkipped()) { @@ -4811,6 +4879,17 @@ function DesignSystemProjectPanel({ const [designMdBody, setDesignMdBody] = useState(''); const [savingDesignMd, setSavingDesignMd] = useState(false); const [kitActionBusy, setKitActionBusy] = useState(null); + const [deleteRequest, setDeleteRequest] = useState<{ projectId: string; generation: number } | null>(null); + const [deleteFailed, setDeleteFailed] = useState(false); + const deleteInFlight = useRef(false); + const { history: deleteShareHistory, status: deleteHistoryStatus } = useProjectShareHistoryState( + deleteRequest && workspaceContext ? deleteRequest.projectId : undefined, + workspaceContext, 'delete-confirmation', + ); + const deleteShareStatus = deleteRequest && (!workspaceContext || deleteRequest.projectId !== projectId + || deleteRequest.generation !== currentWorkspaceAccountGeneration()) ? 'error' : deleteHistoryStatus; + const deleteActiveShareCount = deleteShareHistory + ? deleteShareHistory.publications.filter(publication => publication.status === 'active').length : null; // Transient feedback for kit edits (upload / refresh / reset / delete) so an // action that previously fired-and-forgot now reports success or failure. const [kitToast, setKitToast] = useState<{ message: string; tone: DesignKitActionFeedbackTone } | null>(null); @@ -4999,11 +5078,12 @@ function DesignSystemProjectPanel({ // navigates home — so the panel unmounts on success and there's no busy reset // to do in the happy path. async function deleteDesignSystemProject() { - if (kitActionBusy || !onDeleteDesignSystemProject || !editable) return; - const ok = window.confirm( - t('ds.deleteProjectConfirm', { title: system.title }), - ); - if (!ok) return; + if (kitActionBusy || deleteInFlight.current || !onDeleteDesignSystemProject || !editable + || !deleteRequest || deleteRequest.projectId !== projectId + || deleteRequest.generation !== currentWorkspaceAccountGeneration() + || deleteShareStatus !== 'ready' || deleteActiveShareCount === null) return; + deleteInFlight.current = true; + setDeleteFailed(false); setKitActionBusy('delete'); notifyKitLoading(t('ds.deleteProjectAction', { title: system.title })); try { @@ -5015,14 +5095,16 @@ function DesignSystemProjectPanel({ // aren't user-editable; that's fine. const deleted = await onDeleteDesignSystemProject(projectId); if (!deleted) { - notifyKit('error', t('ds.actionFailed')); - setKitActionBusy(null); + setDeleteFailed(true); return; } + setDeleteRequest(null); await deleteDesignSystemDraft(system.id, workspaceContext); await onDesignSystemsRefresh?.(); } catch { - notifyKit('error', t('ds.actionFailed')); + setDeleteFailed(true); + } finally { + deleteInFlight.current = false; setKitActionBusy(null); } } @@ -5171,7 +5253,7 @@ function DesignSystemProjectPanel({ ? sectionReviews.filter((item) => designSystemSectionVisibleDuringGeneration(item)) : sectionReviews; const groupedSectionReviews = designSystemReviewGroups(visibleSectionReviews); - const reviewTocGroups = groupedSectionReviews + const _reviewTocGroups = groupedSectionReviews .map((group) => ({ title: group.title, items: group.items.map((item) => ({ @@ -5270,7 +5352,7 @@ function DesignSystemProjectPanel({ setFeedbackText(''); } - function renderReviewCard( + function _renderReviewCard( item: DesignSystemProjectSectionReview, instanceId: string, defaultExpanded: boolean, @@ -5566,7 +5648,10 @@ function DesignSystemProjectPanel({ id: 'delete', label: t('ds.deleteProjectAction', { title: system.title }), icon: 'trash' as IconName, - onClick: () => void deleteDesignSystemProject(), + onClick: () => { + setDeleteFailed(false); + setDeleteRequest({ projectId, generation: currentWorkspaceAccountGeneration() }); + }, disabled: !editable || Boolean(kitActionBusy) || statusBusy || defaultBusy, loading: kitActionBusy === 'delete', } satisfies HeaderMenuAction, @@ -5672,6 +5757,21 @@ function DesignSystemProjectPanel({ return (
+ {deleteRequest ? ( + { + if (deleteInFlight.current) return; + setDeleteRequest(null); + setDeleteFailed(false); + }} + onConfirm={() => void deleteDesignSystemProject()} + /> + ) : null} {kitToast ? ( void; + observedPublicShareLink?: ObservedPublicShareLink | null; + loginUpdateRequest?: ObservedShareUpdateRequest | null; + onLoginUpdateRequestHandled?: (nonce: number) => void; amrAuthRetryContinuation?: AmrAuthRetryContinuation | null; onArmAmrAuthRetryContinuation?: ( continuation: Omit, @@ -992,7 +994,7 @@ let liveArtifactEventSequence = 0; // The brand-extraction project's design-system (brand kit) preview tab. Mirrors // the daemon `BRAND_KIT_FILE` (apps/daemon/src/brands/kit-render.ts); kept as a // local literal to respect the web↔daemon boundary. -const BRAND_KIT_FILE = 'brand.html'; +const _BRAND_KIT_FILE = 'brand.html'; const BRAND_EMPTY_TRANSCRIPT_RETRY_DELAYS_MS = [120, 500, 1_200, 2_000] as const; const BYOK_OPENCODE_UNAVAILABLE_MESSAGE = 'BYOK API runs require OpenCode. Install OpenCode, then rescan local agents in Settings before retrying.'; @@ -2114,6 +2116,10 @@ export function ProjectView({ resolveAuthoritativeProjectName, routeFileName, routeConversationId = null, + onObservedPublicShareLink, + observedPublicShareLink, + loginUpdateRequest, + onLoginUpdateRequestHandled, config, agents, skills, @@ -12401,7 +12407,11 @@ export function ProjectView({ const handleArtifactShare = useCallback( (fileName: string, anchorId?: string) => { requestOpenFile(fileName); - setShareRequest({ name: fileName, nonce: Date.now(), ...(anchorId ? { anchorId } : {}) }); + setShareRequest((previous) => ({ + name: fileName, + nonce: nextShareRequestNonce(previous?.nonce, Date.now()), + ...(anchorId ? { anchorId } : {}), + })); }, [requestOpenFile], ); @@ -13188,7 +13198,7 @@ export function ProjectView({ // Continue in CLI / Finalize design package handlers + keyboard // shortcut wiring. Close to the JSX so the data flow is easy to // trace from the toolbar back to its sources. - const handleFinalize = useCallback(() => { + const _handleFinalize = useCallback(() => { const request = buildFinalizeRequest(config); if (!request) { setProjectActionsToast(buildFinalizeCredentialsMissingToast(config)); @@ -13199,7 +13209,7 @@ export function ProjectView({ }); }, [finalize, config, designMdState]); - const handleCancelFinalize = useCallback(() => { + const _handleCancelFinalize = useCallback(() => { finalize.cancel(); }, [finalize]); @@ -14032,6 +14042,10 @@ export function ProjectView({ filesGeneration={committedFilesGeneration} onRefreshFiles={refreshFileWorkspace} onManualFileWritten={recordManualFileWrite} + onObservedPublicShareLink={onObservedPublicShareLink} + observedPublicShareLink={observedPublicShareLink} + loginUpdateRequest={loginUpdateRequest} + onLoginUpdateRequestHandled={onLoginUpdateRequestHandled} isDeck={isDeck} streaming={currentConversationActionDisabled} // The building preview needs a real run, not the disabled-actions @@ -14041,6 +14055,7 @@ export function ProjectView({ runInFlight={currentConversationStreaming || currentConversationHasActiveRun} commentQueueOnSend={commentQueueOnSend} commentSendDisabled={currentConversationQueueDisabled} + routeFileName={routeFileName} openRequest={openRequest} browserOpenRequest={browserOpenRequest} pinnedBrowserTabId={projectIsProgrammaticBrandExtraction ? BRAND_BROWSER_TAB_ID : null} @@ -14479,7 +14494,7 @@ function normalizeProjectFileName(value: string): string { return value.replace(/\\/g, '/').replace(/^\.?\//, '').toLowerCase(); } -function assistantAgentDisplayName( +function _assistantAgentDisplayName( agentId: string | null, fallbackName?: string, ): string | undefined { diff --git a/apps/web/src/components/RecentProjectsStrip.tsx b/apps/web/src/components/RecentProjectsStrip.tsx index eda9dae2f7..5bc9b49d51 100644 --- a/apps/web/src/components/RecentProjectsStrip.tsx +++ b/apps/web/src/components/RecentProjectsStrip.tsx @@ -25,17 +25,17 @@ import { fetchProjectFiles, invalidateProjectFilesCache, } from '../providers/registry'; -import type { DesignSystemSummary, Project, ProjectDisplayStatus } from '../types'; +import type { DesignSystemSummary, Project } from '../types'; import { Icon } from './Icon'; import type { IconName } from './Icon'; import { RemixIcon } from './RemixIcon'; import { InviteDialog } from './InviteDialog'; import { ProjectDeleteConfirmDialog } from './project-actions/ProjectDeleteConfirmDialog'; import { useProjectDeleteFlow } from './project-actions/useProjectDeleteFlow'; +import { useBatchProjectShareCount } from './project-actions/useBatchProjectShareCount'; import { useProjectDuplicateFlow } from './project-actions/useProjectDuplicateFlow'; import { useWorkspaceProjectMove } from './project-actions/useWorkspaceProjectMove'; -import { STATUS_LABEL_KEYS } from './DesignsTab'; -import { isDesignSystemProject, isPublishedDesignSystemProject } from './design-system-project'; +import { isDesignSystemProject } from './design-system-project'; import type { SharedProjectPredicate } from '../collab/all-projects-list'; import { useTeamMembers } from '../collab/useTeamMembers'; import { @@ -79,7 +79,7 @@ import { // decision; re-exported so its existing importers keep resolving. export { deckPreviewSrcDoc } from '../lib/project-cover-pipeline'; import { useInView } from './plugins-home/useInView'; -import { workspaceIdentityCacheKey } from '../collab/workspace-identity'; +import { currentWorkspaceAccountGeneration, workspaceIdentityCacheKey } from '../collab/workspace-identity'; import { useAnalytics } from '../analytics/provider'; import { trackProjectCollectionClick, @@ -97,16 +97,11 @@ import type { ProjectCollectionClickProps } from '@open-design/contracts/analyti * = home's mixed private/shared, 'drafts' = the member's own private list, * 'team' = the全部项目 grid where every card is a team-shared project. */ export type SpaceKind = 'recent' | 'drafts' | 'team'; -import { - projectCoverUrl, - selectProjectFileCover, - type ProjectCoverOverride, -} from './project-cover'; +import { projectCoverUrl, type ProjectCoverOverride } from './project-cover'; interface Props { projects: Project[]; - /** Used only to show a "Published" status for design-system projects whose - * backing system is published (independent of the project's run status). */ + /** Retained for existing callers; the collection card no longer renders a publishing badge. */ designSystems?: DesignSystemSummary[]; /** Retained for call-site compatibility; the strip skips rendering * while the list is loading so we never need a loading state. */ @@ -166,7 +161,6 @@ interface Props { isActive?: boolean; } -const EMPTY_DESIGN_SYSTEMS: DesignSystemSummary[] = []; /** Fallback for a caller with no sharing surface (no workspace, no grids). */ const NOTHING_SHARED: SharedProjectPredicate = () => false; /** The chip a design-system project wears on its card. Product name, not a @@ -364,7 +358,6 @@ class BackgroundTaskQueue { export function RecentProjectsStrip({ projects, - designSystems = EMPTY_DESIGN_SYSTEMS, heading, description, onOpen, @@ -689,6 +682,10 @@ export function RecentProjectsStrip({ // space, delete); nothing new is exposed here that a single card cannot do. const selectedProjects = visibleProjects.filter(({ project }) => selectedProjectIds.has(project.id)); const selectedCount = selectedProjectIds.size; + const batchShares = useBatchProjectShareCount( + bulkDeleteOpen ? selectedProjects.map(({ project }) => project.id) : [], + workspaceContext, + ); // Same gate as the per-card menu: only your own projects can be moved or // deleted, so a selection containing someone else's shared project disables // the mutations instead of half-applying them. @@ -1185,6 +1182,8 @@ export function RecentProjectsStrip({ } async function commitBulkDelete() { + if (batchShares.pending || batchShares.failed || batchShares.count === null || + batchShares.generation !== currentWorkspaceAccountGeneration()) return; const ids = selectedProjects.map(({ project }) => project.id); const startedAt = performance.now(); setBulkDeleteOpen(false); @@ -1550,16 +1549,6 @@ export function RecentProjectsStrip({ workspaceContext, ); const designSystemProject = isDesignSystemProject(project); - const status: ProjectDisplayStatus = project.status?.value ?? 'not_started'; - const publishedDesignSystem = isPublishedDesignSystemProject(project, designSystems); - const isActive = - !publishedDesignSystem && - (status === 'running' || - status === 'queued' || - status === 'awaiting_input' || - // Incomplete is terminal but needs attention; show the status dot so - // it reads as "not done", not a static success pill (#1247 / #1060). - status === 'incomplete'); const shared = isShared(project.id); const selected = selectedProjectIds.has(project.id); const readonlyShared = shared && !creator.ownedBySelf; @@ -1957,8 +1946,10 @@ export function RecentProjectsStrip({ void deleteFlow.commit()} /> @@ -2053,6 +2044,11 @@ export function RecentProjectsStrip({ {t('designs.deleteSelectedConfirm', { n: selectedCount })} + {batchShares.count !== null && batchShares.count > 0 ? ( + {t('designs.deleteActiveShares', { count: batchShares.count })} + ) : null} + {batchShares.pending ?

{t('common.loading')}

: null} + {batchShares.failed ?

{t('ds.actionFailed')}

: null} - -
    {notice.rows.map(row =>
  • -
    - {row.filePath} - {row.slug} -

    {t(row.retrying ? 'designs.deletedShareRetrying' : 'designs.deletedShareFailed')}

    - {row.failed ?

    {t('ds.actionFailed')}

    : null} -
    - {!row.retrying ? : null} -
  • )}
- )} - ; + const rows = state.notices.flatMap(notice => notice.rows.map(row => ({ notice, row }))); + const current = rows.find(({ row }) => !row.retrying) ?? rows[0]; + if (!current) return null; + const { notice, row } = current; + const loading = Boolean(row.retrying || row.busy); + return { void state.retry(notice, row); }} + onDismiss={() => state.dismissRow(notice.id, row)} + />; } diff --git a/apps/web/src/components/project-actions/ProjectDeleteConfirmDialog.module.css b/apps/web/src/components/project-actions/ProjectDeleteConfirmDialog.module.css new file mode 100644 index 0000000000..e13776c365 --- /dev/null +++ b/apps/web/src/components/project-actions/ProjectDeleteConfirmDialog.module.css @@ -0,0 +1,51 @@ +.activeShareDialog:global(.modal-confirm) { + width: 304px; + gap: 12px; +} + +.activeShareFooter:global(.row) { + margin-top: 8px; + gap: 8px; +} + +.activeShareDialog:global(.modal-confirm) .activeShareFooter:global(.row) .activeShareCancel, +.activeShareDialog:global(.modal-confirm) .activeShareFooter:global(.row) .activeShareDelete { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + height: 28px; + min-height: 28px; + padding: 0; + border: 1px solid transparent; + border-radius: 5px; + font-size: 12px; + font-weight: 500; + line-height: 1; + cursor: pointer; +} + +.activeShareDialog:global(.modal-confirm) .activeShareFooter:global(.row) :global(button).activeShareCancel { + width: 48px; + background: #EDEDF0; + border-color: #EDEDF0; + color: #333; +} + +.activeShareDialog:global(.modal-confirm) .activeShareFooter:global(.row) :global(button.primary.danger).activeShareDelete, +.activeShareDialog:global(.modal-confirm) .activeShareFooter:global(.row) :global(button.primary.danger).activeShareDelete:hover, +.activeShareDialog:global(.modal-confirm) .activeShareFooter:global(.row) :global(button.primary.danger).activeShareDelete:active, +.activeShareDialog:global(.modal-confirm) .activeShareFooter:global(.row) :global(button.primary.danger).activeShareDelete:disabled, +.activeShareDialog:global(.modal-confirm) .activeShareFooter:global(.row) :global(button.primary.danger).activeShareDelete:disabled:hover { + width: 76px; + background: rgb(210, 59, 60); + border-color: rgb(210, 59, 60); + color: #fff; +} + +.activeShareDialog:global(.modal-confirm) .activeShareFooter:global(.row) :global(button.primary.danger).activeShareDelete:hover:not(:disabled) { + filter: brightness(0.95); +} + +.activeShareDialog .activeShareFooter button:disabled { cursor: not-allowed; opacity: 0.55; } diff --git a/apps/web/src/components/project-actions/ProjectDeleteConfirmDialog.tsx b/apps/web/src/components/project-actions/ProjectDeleteConfirmDialog.tsx index cc0e675120..3bed395b61 100644 --- a/apps/web/src/components/project-actions/ProjectDeleteConfirmDialog.tsx +++ b/apps/web/src/components/project-actions/ProjectDeleteConfirmDialog.tsx @@ -2,6 +2,8 @@ import { useId } from 'react'; import { Dialog, DialogDescription, DialogFooter, DialogTitle } from '@open-design/components'; import { useT } from '../../i18n'; +import type { ProjectShareReadStatus } from '../share/useProjectShareHistory'; +import styles from './ProjectDeleteConfirmDialog.module.css'; /** * The one delete confirmation every project entry point shows (OPEND-2797): @@ -16,15 +18,19 @@ import { useT } from '../../i18n'; export function ProjectDeleteConfirmDialog({ projectName, activeShareCount = null, + shareReadStatus, pending, failed, + errorMessage, onCancel, onConfirm, }: { projectName: string; activeShareCount?: number | null; + shareReadStatus: ProjectShareReadStatus; pending: boolean; failed: boolean; + errorMessage?: string | null; onCancel: () => void; onConfirm: () => void; }) { @@ -33,7 +39,7 @@ export function ProjectDeleteConfirmDialog({ const hasActiveShares = activeShareCount !== null && Number.isSafeInteger(activeShareCount) && activeShareCount > 0; return ( { if (pending) return; @@ -47,27 +53,31 @@ export function ProjectDeleteConfirmDialog({ {hasActiveShares ? t('designs.deleteConfirm', { name: projectName }) : t('designs.deleteTitle')} - {hasActiveShares - ? t('designs.deleteActiveShares', { count: activeShareCount }) - : t('designs.deleteConfirm', { name: projectName })} + + {shareReadStatus === 'error' ? t('ds.actionFailed') + : shareReadStatus !== 'ready' ? t('common.loading') + : hasActiveShares ? t('designs.deleteActiveShares', { count: activeShareCount }) + : t('designs.deleteConfirm', { name: projectName })} + {failed ? (

- {t('ds.actionFailed')} + {errorMessage || t('ds.actionFailed')}

) : null} - + - ) : null} +
+

{t(state.backfill.reopened === true + ? (state.backfill.retryable + ? 'fileViewer.commentSync.backfillReopenedRetryingBody' + : 'fileViewer.commentSync.backfillReopenedTerminalBody') + : (state.backfill.retryable + ? 'fileViewer.commentSync.backfillRetryingBody' + : 'fileViewer.commentSync.backfillTerminalBody'))}

+ {state.backfill.retryable ? ( + + ) : null} +
+ {retryError ?

{t('fileViewer.commentSync.backfillFailedBody')}

: null}
); } // Absent or `unknown` align is "not checked" — neither a warning nor a // clean bill of health. Only `diverged` is actionable. - if (state.align?.state === 'diverged') { + if (!backfillOnly && state.align?.state === 'diverged') { return (

{t('fileViewer.commentSync.alignFailedBody')}

-
diff --git a/apps/web/src/components/share/ShareEntry.module.css b/apps/web/src/components/share/ShareEntry.module.css index 8d4bffc9af..08ba2f6f86 100644 --- a/apps/web/src/components/share/ShareEntry.module.css +++ b/apps/web/src/components/share/ShareEntry.module.css @@ -51,6 +51,45 @@ color: #333333; } +/* Final G1/G2 uses a 300px reference card: 12px insets + 8px gap make + each 30px-high action about 133px. Multi-card grids keep two columns, + but narrow HTML cards stack the actions rather than painting across them. */ +:global(.artifact-card[data-kind='html']):has(.cardActions) { + container-type: inline-size; +} +:global(.artifact-cards):has(.cardActions):has(> :global(.artifact-card:only-child[data-kind='html'])) { + max-width: 300px; +} +:global(.artifact-card) .cardActions:global(.artifact-card-acts) { + inset-inline: 12px; + gap: 8px; +} +:global(.artifact-card) .cardActions > :global(.artifact-card-act) { + flex: 1; + min-width: 0; +} +:global(.artifact-card) .cardActionLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +@container (max-width: 245px) { + :global(.artifact-card) .cardActions:global(.artifact-card-acts) { + flex-direction: column; + align-items: stretch; + gap: 4px; + } + :global(.artifact-card) .cardActions > :global(.artifact-card-act) { + width: 100%; + } +} +:global(.artifact-card) .cardPublished:global(.artifact-card-act), +:global(.artifact-card) .cardPublished:global(.artifact-card-act):hover:not(:disabled) { + background: #E3F1E6; + color: #2F6B3F; +} + /* Disabled appearance applies to the existing readonly/generation gates only. */ :global(.artifact-card) .card:global(.artifact-card-act):disabled { background: #F3F3F1; diff --git a/apps/web/src/components/share/ShareMoreMenu.module.css b/apps/web/src/components/share/ShareMoreMenu.module.css index b0251766ca..9a96e40ba0 100644 --- a/apps/web/src/components/share/ShareMoreMenu.module.css +++ b/apps/web/src/components/share/ShareMoreMenu.module.css @@ -6,7 +6,7 @@ button.trigger { display: flex; align-items: center; justify-content: center; } .menu { - position: absolute; inset-inline-end: 0; top: calc(100% + 4px); z-index: 1; + position: fixed; inset-inline-end: auto; top: auto; z-index: 1000; box-sizing: border-box; width: 214px; padding: 4px; display: flex; flex-direction: column; gap: 2px; border: 1px solid #00000008; border-radius: 8px; background: #fff; diff --git a/apps/web/src/components/share/ShareMoreMenu.tsx b/apps/web/src/components/share/ShareMoreMenu.tsx index 38de9def72..91112e670b 100644 --- a/apps/web/src/components/share/ShareMoreMenu.tsx +++ b/apps/web/src/components/share/ShareMoreMenu.tsx @@ -1,4 +1,5 @@ -import { useEffect, useId, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; import { Button } from '@open-design/components'; import styles from './ShareMoreMenu.module.css'; @@ -11,9 +12,14 @@ interface ShareMoreAction { onSelect: () => void; } +const MENU_WIDTH = 214; +const VIEWPORT_GUTTER = 8; +const MENU_GAP = 4; + /** Disclosure only: deployment, permissions and pending work remain owned by the host. */ export function ShareMoreMenu({ label, items }: { label: string; items: readonly ShareMoreAction[] }) { const [open, setOpen] = useState(false); + const [position, setPosition] = useState({}); const root = useRef(null); const trigger = useRef(null); const menu = useRef(null); @@ -26,35 +32,79 @@ export function ShareMoreMenu({ label, items }: { label: string; items: readonly setOpen(false); if (restoreFocus) trigger.current?.focus(); } + useLayoutEffect(() => { + if (!open) return; + const placeMenu = () => { + const anchor = trigger.current?.getBoundingClientRect(); + if (!anchor) return; + const viewportWidth = document.documentElement.clientWidth || window.innerWidth; + const maxWidth = Math.max(0, viewportWidth - VIEWPORT_GUTTER * 2); + const width = Math.min(MENU_WIDTH, maxWidth); + const panel = root.current?.closest('.share-menu-popover')?.getBoundingClientRect() ?? anchor; + const rightSpace = viewportWidth - panel.right - VIEWPORT_GUTTER; + const leftSpace = panel.left - VIEWPORT_GUTTER; + // Place beside the whole share panel, not beside its header button: a + // button-relative left placement can put the portal underneath the panel. + const left = rightSpace >= width + MENU_GAP + ? panel.right + MENU_GAP + : leftSpace >= width + MENU_GAP + ? panel.left - width - MENU_GAP + : rightSpace >= leftSpace + ? Math.min(viewportWidth - width - VIEWPORT_GUTTER, panel.right + MENU_GAP) + : Math.max(VIEWPORT_GUTTER, panel.left - width - MENU_GAP); + const menuHeight = menu.current?.getBoundingClientRect().height ?? 0; + const top = Math.max(VIEWPORT_GUTTER, Math.min(anchor.top, window.innerHeight - menuHeight - VIEWPORT_GUTTER)); + setPosition({ position: 'fixed', left, top, width, maxWidth: `calc(100vw - ${VIEWPORT_GUTTER * 2}px)` }); + }; + placeMenu(); + window.addEventListener('resize', placeMenu); + window.addEventListener('scroll', placeMenu, true); + return () => { + window.removeEventListener('resize', placeMenu); + window.removeEventListener('scroll', placeMenu, true); + }; + }, [open]); useEffect(() => { if (!open) return; const buttons = enabledItems(); (startAtEnd.current ? buttons.at(-1) : buttons[0])?.focus(); if (!buttons.length) menu.current?.focus(); const outside = (event: PointerEvent) => { - if (event.target instanceof Node && !root.current?.contains(event.target)) setOpen(false); + const target = event.target; + if (target instanceof Node && !root.current?.contains(target) && !menu.current?.contains(target)) setOpen(false); }; document.addEventListener('pointerdown', outside); return () => document.removeEventListener('pointerdown', outside); }, [open]); + const menuNode = ( + + ); return (
{ - if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false); - }} onKeyDown={event => { - if (!open) return; - if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - dismiss(true); - } else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { - event.preventDefault(); - event.stopPropagation(); - const buttons = enabledItems(); - const index = buttons.findIndex(button => button === document.activeElement); - const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 - : (index + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length; - buttons[next]?.focus(); - } + const next = event.relatedTarget; + if (!(next instanceof Node) || (!root.current?.contains(next) && !menu.current?.contains(next))) setOpen(false); }}> - + {typeof document !== 'undefined' ? createPortal(menuNode, document.body) : null}
); } diff --git a/apps/web/src/components/share/ShareTab.module.css b/apps/web/src/components/share/ShareTab.module.css index dbf430e139..1b9073cb24 100644 --- a/apps/web/src/components/share/ShareTab.module.css +++ b/apps/web/src/components/share/ShareTab.module.css @@ -12,13 +12,37 @@ background: #FFFFFF; box-shadow: 0 8px 28px #00000010, 0 2px 6px #00000006; font-size: 12px; - font-weight: 500; + font-weight: 400; color: #333333; } -/* S1/S4: the "链接访问" title row + static description line the panel was - missing (板 D-00 固定结构第二/三行). The help "?" tooltip keeps its prior - position, at the row's trailing edge. */ +/* S0/S13: signed-out explanation and existing Cloud login action. */ +.signInPrompt { + display: flex; + flex-direction: column; + gap: 14px; +} + +.signInAction { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + min-height: 32px; + padding: 6px 12px; + border: 1px solid #D6D6D6; + border-radius: 6px; + background: #282828; + color: #FFFFFF; + cursor: pointer; + font-size: 12px; + line-height: 20px; +} + +.signInAction:focus-visible { outline: 2px solid #1677FF; outline-offset: 2px; } +.signInAction:global(.is-signing) { cursor: default; background: #F7F7F7; color: #333333; } + +/* S1/S4: link access heading and description. */ .linkAccessHeading { display: flex; flex-direction: column; @@ -47,11 +71,6 @@ line-height: 18px; } -.linkAccessRowEnd { - display: flex; - align-items: center; - gap: 8px; -} /* D-00 固定结构第二行 .toggle: 32×19 / r999 / 关 #D6D6D6 / 开 #282828, 滑块 15×15 白. This IS the publish/unpublish control — board S4 has no separate "stop @@ -322,6 +341,21 @@ button.copyButton.publishingButton:disabled { gap: 6px; } +/* S16 advisory: missing references do not prevent publication. */ +.sharePlanWarning { + padding: 8px 10px; + border: 1px solid #EFE0C8; + border-radius: 6px; + background: #FDF6EC; + color: #8A5A12; + font-size: 12px; +} + +.sharePlanWarning p { margin: 0 0 4px; } +.sharePlanWarning button { color: inherit; text-decoration: underline; } +.sharePlanWarning ul { margin: 6px 0 0; padding-left: 18px; } +.sharePlanWarning code { overflow-wrap: anywhere; } + /* S2 help describes panel dismissal, not quitting the app. */ p.publishHint { margin: 0; @@ -330,13 +364,18 @@ p.publishHint { line-height: 17px; } -/* S11 (◇): preserve the full selectable URL; only clipboard failure gets - this fallback treatment. Successful/idle links retain their existing skin. */ -.copyFallback:global(.chrome-publish-url) { +/* S3/S4: match the final-design link/copy stack without affecting FileViewer’s separate renderer. */ +.publishedLink { + gap: 8px; + padding: 0; +} + +.publishedLink :global(.chrome-publish-url) { box-sizing: border-box; + position: relative; width: 100%; height: 32px; - padding: 0 9px; + padding: 0 9px 0 29px; border: 1px solid #E5E5E5; border-radius: 6px; background: #FFFFFF; @@ -351,6 +390,46 @@ p.publishHint { cursor: text; } +/* S5: the copy row precedes its separate update notice and action. */ +.updateNotice { + display: grid; + gap: 8px; +} + +.updateNotice p { + margin: 0; + color: #888888; + font-size: 11px; + line-height: 17px; +} + +button.updateButton { + width: 100%; + min-height: 32px; + border: 1px solid #DEDEDE; + border-radius: 6px; + background: #FFFFFF; + color: #29292B; + font-size: 12px; +} + +button.updateButton:hover:not(:disabled) { + background: #F7F7F7; +} + +.publishedLinkIcon { + position: absolute; + left: 9px; + top: 9px; + color: #888888; +} + +.publishedLink :global(.chrome-publish-actions) { + display: block; + width: 100%; +} + + .copyHint { margin: 0; color: #888888; diff --git a/apps/web/src/components/share/ShareTab.tsx b/apps/web/src/components/share/ShareTab.tsx index 739adfa27f..b603f83768 100644 --- a/apps/web/src/components/share/ShareTab.tsx +++ b/apps/web/src/components/share/ShareTab.tsx @@ -1,14 +1,27 @@ -import { useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react'; +import { useEffect, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react'; +import { fetchProjectFileSharePlan } from '../../providers/registry'; +import type { SharePlanSummary } from '@open-design/contracts'; import { useShareScopeKeyboard } from './useShareScopeKeyboard'; import { Button } from '@open-design/components'; -import { workspaceContextHasTeamIdentity, type WorkspaceCollabContext } from '@open-design/contracts'; +import { SHARE_MAX_TOTAL_BYTES, shareEntryPresentation, workspaceContextHasTeamIdentity, type ShareContentFreshness, type WorkspaceCollabContext } from '@open-design/contracts'; import type { PublicFilePublishFailureKey } from '../../collab/public-file-publish'; import type { useT } from '../../i18n'; import { RemixIcon } from '../RemixIcon'; +import { CloudSignInTip } from '../CloudSignInTip'; +import { CommentSyncBanner } from './CommentSyncBanner'; import styles from './ShareTab.module.css'; - export type SharePublishFailureKey = PublicFilePublishFailureKey | 'fileViewer.publishFileTooLarge' | 'fileViewer.unpublishFileFailed'; +/** Deployment custom domains may vary; only open browser-safe HTTP(S) URLs. */ +function browsableSharePageUrl(rawUrl: string): string | null { + try { + const url = new URL(rawUrl, window.location.origin); + return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null; + } catch { + return null; + } +} + /** Time-based waiting feedback, not transferred bytes. Only success may reach 1. */ export function boundedPublishProgress(elapsedMs: number, completed: boolean): number { if (completed) return 1; @@ -30,6 +43,12 @@ function PublishProgressFrame({ value, label, children }: { value: number | null export function ShareTab({ menuOrigin, publicationStatus = null, + publicationFreshness = 'unknown', + updateCurrentFilePublic, + canResumeUpdateAfterLogin = false, + onUpdateLoginSuccess, + projectId, + filePath, workspaceContext, t, shareAccess, @@ -61,6 +80,12 @@ export function ShareTab({ menuOrigin: 'toolbar' | 'artifact-card'; /** Exact file status from authoritative project share-state, not local URL presence. */ publicationStatus?: 'active' | 'stopped' | null; + publicationFreshness?: ShareContentFreshness; + updateCurrentFilePublic?: () => Promise; + canResumeUpdateAfterLogin?: boolean; + onUpdateLoginSuccess?: () => void; + projectId?: string; + filePath?: string; workspaceContext: WorkspaceCollabContext | null; t: ReturnType; shareAccess: 'private' | 'workspace'; @@ -78,7 +103,7 @@ export function ShareTab({ publishProgress: number | null; unpublishCurrentFilePublic: () => Promise; viewerOnlyDisabledTitle: string; - publishCurrentFilePublic: () => Promise; + publishCurrentFilePublic: (mode?: 'resume') => Promise; publishFailureKey: SharePublishFailureKey | null; streaming: boolean; sharePageUrl: string; @@ -90,6 +115,35 @@ export function ShareTab({ shareLinkStatusHint: string; }) { const [copyingLink, setCopyingLink] = useState(false); + const [sharePlan, setSharePlan] = useState(null); + const [sharePlanPending, setSharePlanPending] = useState(false); + const [sharePlanFailed, setSharePlanFailed] = useState(false); + const [showShareExclusions, setShowShareExclusions] = useState(false); + // S1: selected link access is only an unpublished UI intent. It never + // transfers bytes or creates a URL until Generate and copy is clicked. + const [prepublishLinkAccess, setPrepublishLinkAccess] = useState(true); + useEffect(() => { setPrepublishLinkAccess(true); }, [projectId, filePath]); + // A retained personal-project URL may outlive the signed-in workspace context. + // It remains readable/copyable, but mutations require an authenticated workspace. + const canMutatePublicShare = canPublishPublic && workspaceContext !== null; + const initialUnpublished = !filePublished && publicationStatus == null; + const linkAccessChecked = filePublished || (initialUnpublished && prepublishLinkAccess); + useEffect(() => { + let cancelled = false; + setSharePlan(null); + setSharePlanFailed(false); + if (!projectId || !filePath || !canPublishPublic || viewerOnly || publicationStatus === 'stopped') { setSharePlanPending(false); return; } + setSharePlanPending(true); + void fetchProjectFileSharePlan(projectId, filePath, workspaceContext).then( + (plan) => { if (!cancelled) { setSharePlan(plan); setSharePlanPending(false); } }, + () => { if (!cancelled) { setSharePlan(null); setSharePlanPending(false); setSharePlanFailed(true); } }, + ); + return () => { cancelled = true; }; + }, [projectId, filePath, workspaceContext, canPublishPublic, viewerOnly, publicationStatus]); + const planTooLarge = sharePlan?.exceedsSizeLimit === true; + const updateAvailable = Boolean(updateCurrentFilePublic) && filePublished && shareEntryPresentation({ + status: publicationStatus ?? 'none', freshness: publicationFreshness, + }).appearance === 'outdated'; const copyInFlight = useRef(false); const { scopeTriggerRef, scopeOptionsRef, handleScopeKeyDown } = useShareScopeKeyboard({ open: shareAccessMenuOpen, @@ -119,49 +173,51 @@ export function ShareTab({ : t('fileViewer.commentSync.shareStoppedTeam')}

) : null} - {canPublishPublic ? ( + {canPublishPublic || filePublished ? ( <>
{t('fileViewer.linkAccessTitle')} - - -

{t('fileViewer.linkAccessDescription')}

- {filePublished && publishProgress !== null ? ( - + {publishFailureKey ? ( +

+ + {t(publishFailureKey)} +

+ ) : null} + {planTooLarge && sharePlan ? ( +

+ {t('fileViewer.publishFileTooLarge')} + {' '}({(sharePlan.totalBytes / 1048576).toFixed(2)} MiB / {SHARE_MAX_TOTAL_BYTES / 1048576} MiB; {sharePlan.totalBytes} / {SHARE_MAX_TOTAL_BYTES} B) +

) : null} {filePublished ? ( -
-
+ <> +
+
+ {publishedFileUrl}
@@ -197,42 +253,83 @@ export function ShareTab({ {copyingLink ? t('fileViewer.copyingLink') : publishLinkFeedback === 'copied' - ? t('fileViewer.copied') + ? t('preview.shareCopied') : t('fileViewer.copyShareLink')} -
{publishLinkFeedback === 'failed' ? (

{t('fileViewer.copyLinkManually')}

) : null}
+ {!canMutatePublicShare && canResumeUpdateAfterLogin ? ( +
+

{t('fileViewer.shareOutdatedSignInHint')}

+ +
+ ) : null} + {updateAvailable ? ( +
+

{t('fileViewer.shareUpdateHint')}

+ +
+ ) : null} + ) : ( - + <> + {sharePlanFailed ? ( +
{t('fileViewer.sharePlanUnavailable')}
+ ) : null} + {sharePlan && Array.isArray(sharePlan.exclusions) && sharePlan.exclusions.length > 0 ? ( +
+

{t('fileViewer.shareMissingRefs')}

+ + {showShareExclusions ?
    {sharePlan.exclusions.map((item, index) =>
  • {item.path} — {item.reason === 'missing' ? t('fileViewer.shareMissingRefsToggle') : t('fileViewer.shareInvalidRefs')}
  • )}
: null} +
+ ) : null} + {publicationStatus !== 'stopped' || publishingPublicFile ? ( + + ) : null} + ) } - {publishingPublicFile && !filePublished ? ( + {publishingPublicFile && !filePublished && publicationStatus !== 'stopped' ? (

{t('fileViewer.publishingContinuesOnClose')}

) : null} - {publishFailureKey ? ( -

- - {t(publishFailureKey)} -

- ) : null} - ) : null} + ) : ( +
+
+ {t('fileViewer.linkAccessTitle')} +

{t(canResumeUpdateAfterLogin ? 'fileViewer.shareOutdatedSignInHint' : 'fileViewer.publishFileRequiresWorkspace')}

+
+ {filePublished && publishedFileUrl ? ( +
+ {publishedFileUrl} + +
+ ) : null} + +
+ )} {/* Team-only, same as ReactComponentViewer's copy of this card above — see the comment there (recvq5bM78HWCE). */} - {menuOrigin === 'toolbar' && workspaceContextHasTeamIdentity(workspaceContext) ? ( + {workspaceContextHasTeamIdentity(workspaceContext) ? ( <>
@@ -401,7 +501,8 @@ export function ShareTab({ } onClick={() => { if (!canOpenSharePage) return; - window.open(sharePageUrl, '_blank', 'noopener'); + const url = browsableSharePageUrl(sharePageUrl); + if (url) window.open(url, '_blank', 'noopener,noreferrer'); }} > @@ -416,6 +517,9 @@ export function ShareTab({ ) : null} ) : null} + {menuOrigin === 'toolbar' && filePublished ? ( + + ) : null}
); } diff --git a/apps/web/src/components/share/SignedOutObservedShare.module.css b/apps/web/src/components/share/SignedOutObservedShare.module.css new file mode 100644 index 0000000000..210189be36 --- /dev/null +++ b/apps/web/src/components/share/SignedOutObservedShare.module.css @@ -0,0 +1,73 @@ +.shell { + display: grid; + min-height: 100%; + place-items: center; + padding: 24px; + background: #f6f6f6; +} + +.panel { + box-sizing: border-box; + display: grid; + gap: 12px; + width: min(100%, 360px); + padding: 20px; + border-radius: 12px; + background: #fff; + box-shadow: 0 8px 28px #00000010, 0 2px 6px #00000006; + color: #333; + text-align: start; +} + +.title { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 24px; +} + +.url { + display: block; + overflow: hidden; + padding: 8px 10px; + border: 1px solid #ededed; + border-radius: 6px; + color: #777; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + user-select: text; +} + +.copy { + width: 100%; + min-height: 32px; + background: #282828; + color: #fff; +} + +.updateNotice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: #888; + font-size: 12px; + line-height: 18px; +} + +.updateNotice p { margin: 0; } + +.signInAction { + flex: none; + padding: 6px 10px; + border-radius: 6px; + background: #f3f7ff; + color: #1677ff; + cursor: pointer; +} + +.signInAction :global(.entry-local-mode-tip__login-badge) { + background: transparent; + color: inherit; +} diff --git a/apps/web/src/components/share/SignedOutObservedShare.tsx b/apps/web/src/components/share/SignedOutObservedShare.tsx new file mode 100644 index 0000000000..35770b93f5 --- /dev/null +++ b/apps/web/src/components/share/SignedOutObservedShare.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import { Button } from '@open-design/components'; +import { useT } from '../../i18n'; +import styles from './SignedOutObservedShare.module.css'; +import shareStyles from './ShareTab.module.css'; +import { CloudSignInTip } from '../CloudSignInTip'; + +/** Only the previously observed public URL is visible; no project source is mounted. */ +export function SignedOutObservedShare({ url, canUpdate, onLoginUpdateSuccess }: { + url: string; canUpdate: boolean; onLoginUpdateSuccess: () => void; +}) { + const t = useT(); + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle'); + return ( +
+
+

{t('fileViewer.share')}

+
+
+ {t('fileViewer.linkAccessTitle')} + +
+

{t('fileViewer.linkAccessDescription')}

+
+ {url} + + {copyState === 'failed' ?

{t('fileViewer.copyLinkManually')}

: null} + {canUpdate ?
+

{t('fileViewer.shareOutdatedSignInHint')}

+ +
: null} +
+
+ ); +} diff --git a/apps/web/src/components/share/observed-public-share-link.ts b/apps/web/src/components/share/observed-public-share-link.ts new file mode 100644 index 0000000000..4f7889a065 --- /dev/null +++ b/apps/web/src/components/share/observed-public-share-link.ts @@ -0,0 +1,21 @@ +// A volatile capability witnessed through an authorized active-share GET. +// Never persist it: account/project/file changes must invalidate this copy-only fallback. +export interface ObservedPublicShareLink { + status: 'active'; + projectId: string; + filePath: string; + slug: string; + url: string; + workspaceId: string; + workspaceMemberId: string; + authorizationScopeKey: string; + freshness: 'current' | 'outdated' | 'unknown'; +} + +/** Explicit click on sign-in-to-update; expires and never survives App unmount. */ +export interface ObservedShareUpdateRequest { + nonce: number; + accountId: string; + expiresAt: number; + link: ObservedPublicShareLink; +} diff --git a/apps/web/src/components/share/share-publication-events.ts b/apps/web/src/components/share/share-publication-events.ts new file mode 100644 index 0000000000..94643ee151 --- /dev/null +++ b/apps/web/src/components/share/share-publication-events.ts @@ -0,0 +1,15 @@ +export const PROJECT_SHARE_HISTORY_CHANGED_EVENT = 'od:project-share-history-changed'; + +/** Only a successful stop mutation can confirm one exact file as no longer active. */ +export interface ConfirmedProjectShareStop { + sourceFilePath: string; + accountScope: string; + generation: number; +} + +/** Refresh for all mutations; an optional scoped stop receipt removes stale active feedback during a failed refresh. */ +export function notifyProjectShareHistoryChanged(projectId: string, confirmedStop?: ConfirmedProjectShareStop): void { + window.dispatchEvent(new CustomEvent(PROJECT_SHARE_HISTORY_CHANGED_EVENT, { + detail: { projectId, ...(confirmedStop ? { confirmedStop } : {}) }, + })); +} diff --git a/apps/web/src/components/share/useCommentSyncState.ts b/apps/web/src/components/share/useCommentSyncState.ts index d88b343744..79fb5f3c9e 100644 --- a/apps/web/src/components/share/useCommentSyncState.ts +++ b/apps/web/src/components/share/useCommentSyncState.ts @@ -1,8 +1,15 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { CommentSyncState, WorkspaceCollabContext } from '@open-design/contracts'; import { currentWorkspaceAccountGeneration, workspaceAccountScopedCacheKey, workspaceProjectHeaders } from '../../collab/workspace-identity'; import { AMR_LOGIN_STATUS_EVENT } from '../amrLoginPolling'; +const COMMENT_SYNC_STATUS_CHANGED_EVENT = 'od:comment-sync-status-changed'; + +/** A settled share mutation only invalidates the exact currently viewed file. */ +export function invalidateCommentSyncState(projectId: string, filePath: string) { + window.dispatchEvent(new CustomEvent(COMMENT_SYNC_STATUS_CHANGED_EVENT, { detail: { projectId, filePath } })); +} + function isCommentSyncState(value: unknown): value is CommentSyncState { if (!value || typeof value !== 'object') return false; return 'pending' in value && Number.isSafeInteger(value.pending) && Number(value.pending) >= 0 @@ -31,17 +38,24 @@ export function useCommentSyncState( const generation = currentWorkspaceAccountGeneration(); const scope = JSON.stringify([projectId, workspaceAccountScopedCacheKey(context), filePath ?? null]); const [observation, setObservation] = useState<{ scope: string; value: CommentSyncState } | null>(null); + const refreshRef = useRef<((clearCurrent?: boolean) => void) | null>(null); useEffect(() => { let disposed = false; let attempt = 0; let controller: AbortController | undefined; - const refresh = () => { + let inFlight = false; + const refresh = (clearCurrent = true) => { + if (inFlight && !clearCurrent) return; + if (clearCurrent) { + controller?.abort(); + inFlight = false; + setObservation(null); + } const request = ++attempt; const account = currentWorkspaceAccountGeneration(); - controller?.abort(); - setObservation(null); if (!projectId) return; controller = new AbortController(); + inFlight = true; void (async () => { let value: unknown; try { @@ -55,19 +69,41 @@ export function useCommentSyncState( value = null; // Transport/parse failure remains unknown, never healthy. } if (disposed || request !== attempt || account !== currentWorkspaceAccountGeneration()) return; - setObservation(isCommentSyncState(value) ? { scope, value } : null); + const parsed = isCommentSyncState(value) ? value : null; + if (parsed) setObservation({ scope, value: parsed }); + else if (clearCurrent) setObservation(null); + inFlight = false; })(); }; + const refreshOnLogin = () => refresh(); + const refreshOnFocus = () => refresh(false); + refreshRef.current = refresh; refresh(); - window.addEventListener(AMR_LOGIN_STATUS_EVENT, refresh); - window.addEventListener('focus', refresh); + const refreshOnPublication = (event: Event) => { + const detail = (event as CustomEvent<{ projectId: string; filePath: string }>).detail; + if (detail?.projectId === projectId && detail.filePath === filePath) refresh(); + }; + window.addEventListener(AMR_LOGIN_STATUS_EVENT, refreshOnLogin); + window.addEventListener('focus', refreshOnFocus); + window.addEventListener(COMMENT_SYNC_STATUS_CHANGED_EVENT, refreshOnPublication); return () => { disposed = true; + if (refreshRef.current === refresh) refreshRef.current = null; controller?.abort(); - window.removeEventListener(AMR_LOGIN_STATUS_EVENT, refresh); - window.removeEventListener('focus', refresh); + window.removeEventListener(AMR_LOGIN_STATUS_EVENT, refreshOnLogin); + window.removeEventListener('focus', refreshOnFocus); + window.removeEventListener(COMMENT_SYNC_STATUS_CHANGED_EVENT, refreshOnPublication); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- refreshToken intentionally forces a re-run without joining `scope`. }, [projectId, context, generation, scope, refreshToken]); - return observation?.scope === scope ? observation.value : null; + + const current = observation?.scope === scope ? observation.value : null; + const pollBackfill = Boolean(current && current.sessionMissing === false && current.shareStopped !== true + && (current.backfill?.state === 'pending' || (current.backfill?.state === 'failed' && current.backfill.retryable))); + useEffect(() => { + if (!pollBackfill) return; + const timer = window.setInterval(() => refreshRef.current?.(false), 2_000); + return () => window.clearInterval(timer); + }, [pollBackfill, scope]); + return current; } diff --git a/apps/web/src/components/share/useProjectShareHistory.ts b/apps/web/src/components/share/useProjectShareHistory.ts index c85737e6d4..5590d4e900 100644 --- a/apps/web/src/components/share/useProjectShareHistory.ts +++ b/apps/web/src/components/share/useProjectShareHistory.ts @@ -2,8 +2,9 @@ import { useEffect, useState } from 'react'; import type { ProjectShareHistoryResponse, WorkspaceCollabContext } from '@open-design/contracts'; import { currentWorkspaceAccountGeneration, workspaceAccountScopedCacheKey, workspaceProjectHeaders } from '../../collab/workspace-identity'; import { AMR_LOGIN_STATUS_EVENT } from '../amrLoginPolling'; +import { PROJECT_SHARE_HISTORY_CHANGED_EVENT } from './share-publication-events'; -function isHistory(value: unknown, projectId: string): value is ProjectShareHistoryResponse { +export function isProjectShareHistory(value: unknown, projectId: string): value is ProjectShareHistoryResponse { if (!value || typeof value !== 'object') return false; return 'projectId' in value && value.projectId === projectId && 'hasEverShared' in value && typeof value.hasEverShared === 'boolean' @@ -16,10 +17,12 @@ function isHistory(value: unknown, projectId: string): value is ProjectShareHist } /** Includes stopped bindings. Failed reads are unknown, never "never shared". */ -export function useProjectShareHistory(projectId: string | undefined, context: WorkspaceCollabContext | null | undefined, publicationKey: string) { +export type ProjectShareReadStatus = 'idle' | 'loading' | 'ready' | 'error'; + +export function useProjectShareHistoryState(projectId: string | undefined, context: WorkspaceCollabContext | null | undefined, publicationKey: string) { const generation = currentWorkspaceAccountGeneration(); const scope = JSON.stringify([projectId, workspaceAccountScopedCacheKey(context), publicationKey]); - const [result, setResult] = useState<{ scope: string; history: ProjectShareHistoryResponse } | null>(null); + const [result, setResult] = useState<{ scope: string; history: ProjectShareHistoryResponse | null } | null>(null); useEffect(() => { let disposed = false; let attempt = 0; @@ -43,18 +46,29 @@ export function useProjectShareHistory(projectId: string | undefined, context: W value = null; // Do not convert an authorization/transport failure to false. } if (disposed || attempt !== request || account !== currentWorkspaceAccountGeneration()) return; - setResult(isHistory(value, projectId) ? { scope, history: value } : null); + setResult({ scope, history: isProjectShareHistory(value, projectId) ? value : null }); })(); }; refresh(); window.addEventListener(AMR_LOGIN_STATUS_EVENT, refresh); window.addEventListener('focus', refresh); + const refreshChangedProject = (event: Event) => { + if (event instanceof CustomEvent && event.detail?.projectId === projectId) refresh(); + }; + window.addEventListener(PROJECT_SHARE_HISTORY_CHANGED_EVENT, refreshChangedProject); return () => { disposed = true; controller?.abort(); window.removeEventListener(AMR_LOGIN_STATUS_EVENT, refresh); window.removeEventListener('focus', refresh); + window.removeEventListener(PROJECT_SHARE_HISTORY_CHANGED_EVENT, refreshChangedProject); }; }, [projectId, context, generation, scope]); - return result?.scope === scope ? result.history : null; + const current = result?.scope === scope ? result : null; + const status: ProjectShareReadStatus = !projectId ? 'idle' : !current ? 'loading' : current.history ? 'ready' : 'error'; + return { history: current?.history ?? null, status }; +} + +export function useProjectShareHistory(projectId: string | undefined, context: WorkspaceCollabContext | null | undefined, publicationKey: string) { + return useProjectShareHistoryState(projectId, context, publicationKey).history; } diff --git a/apps/web/src/i18n/locales/ar.ts b/apps/web/src/i18n/locales/ar.ts index 3cefb2f455..7501badc64 100644 --- a/apps/web/src/i18n/locales/ar.ts +++ b/apps/web/src/i18n/locales/ar.ts @@ -239,6 +239,12 @@ export const ar: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "الرابط معطّل.", 'fileViewer.commentSync.backfillFailedTitle': "تم نشر المشاركة", 'fileViewer.commentSync.backfillFailedBody': "التعليقات الحالية لم تتم مزامنتها بعد؛ لا يمكن للزوار رؤيتها مؤقتًا.", + 'fileViewer.commentSync.backfillTerminalBody': "تعذرت مزامنة بعض التعليقات الحالية مع صفحة المشاركة ولن تتم إعادة المحاولة تلقائيًا. يظل رابط المشاركة متاحًا.", + 'fileViewer.commentSync.backfillRetryingBody': "لم تتم مزامنة التعليقات الحالية بعد، ولا يمكن للزوار رؤيتها مؤقتًا. ستتم إعادة المحاولة تلقائيًا في الخلفية.", + 'fileViewer.commentSync.backfillReopenedPendingBody': "تجري مزامنة تعليقات رابط المشاركة الذي أعيد تفعيله. قد تظل التعليقات المحذوفة أو التي تم حلها ظاهرة على صفحة المشاركة.", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "لم تكتمل مزامنة التعليقات بعد. قد تظل التعليقات المحذوفة أو التي تم حلها ظاهرة على صفحة المشاركة. تجري إعادة المحاولة تلقائيًا في الخلفية.", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "لم تكتمل مزامنة التعليقات بعد. قد تظل التعليقات المحذوفة أو التي تم حلها ظاهرة على صفحة المشاركة. توقفت إعادة المحاولة التلقائية؛ ولا يزال رابط المشاركة متاحًا.", + 'fileViewer.commentSync.backfillPendingBody': "تتم مزامنة التعليقات الحالية. قد لا يتمكن الزوار من رؤيتها بعد.", 'fileViewer.commentSync.alignFailedBody': "لم تكتمل مطابقة التعليقات؛ قد تكون بعض التعليقات غير ظاهرة.", 'fileViewer.shareGuide.title': "اكتمل التصدير", 'fileViewer.shareGuide.description': "شارك الرابط وادعُ الآخرين لعرضه وترك التعليقات.", @@ -2169,6 +2175,8 @@ export const ar: Dict = { 'chat.comments.sending': 'جارٍ الإرسال…', 'chat.comments.edit': 'تحرير', 'chat.comments.select': 'تحديد', + 'chat.comments.expandBody': 'إظهار المزيد', + 'chat.comments.collapseBody': 'إظهار أقل', 'chat.comments.selectAll': 'تحديد الكل', 'chat.comments.deselect': 'إلغاء التحديد', 'chat.comments.nSelected': '{n} محدد', @@ -3079,7 +3087,7 @@ export const ar: Dict = { 'fileViewer.tweaks': 'تعديلات', 'fileViewer.tweaksUnavailable': 'لا توجد لوحة تعديلات في هذا العمل', 'fileViewer.jsxModuleTitle': 'لا توجد معاينة مستقلة', - 'fileViewer.jsxModuleBody': 'هذا الملف وحدة مكوّن يتم تحميلها بواسطة صفحة أخرى.', + 'fileViewer.jsxModuleBody': 'لا يمكن مشاركة سوى ملفات HTML المكتفية ذاتيًا. صدّر هذا المكوّن كملف HTML أولًا.', 'fileViewer.jsxModuleCta': 'افتح الصفحة التي تعرضه:', 'fileViewer.comment': 'تعليق', 'fileViewer.edit': 'تعديل', @@ -3291,7 +3299,19 @@ export const ar: Dict = { 'fileViewer.publishFileRequiresTeam': 'يتطلب نشر رابط عام مساحة عمل جماعية. بدّل إلى مساحة عمل جماعية، أو استخدم النشر لإتاحة هذا الملف.', 'fileViewer.publishFileRequiresWorkspace': 'يتطلب رابط المشاركة مساحة عمل مسجّلة الدخول. سجّل الدخول إلى OpenDesign Cloud أو شارك هذا الملف عبر النشر.', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "يتجاوز هذا المشروع حد المشاركة البالغ 20 MiB. قلّل حجم HTML والموارد المشار إليها، ثم أعد المحاولة.", + "fileViewer.shareUpdateHint": "يحتوي الملف على تغييرات جديدة. حدّث الرابط لمشاركة أحدث إصدار.", + "fileViewer.shareUpdateLink": "تحديث الرابط", + "fileViewer.signInToUpdate": "سجّل الدخول للتحديث", + "fileViewer.shareOutdatedSignInHint": "يحتوي هذا الملف على تغييرات جديدة. سجّل الدخول لتحديث الرابط أو إيقافه.", + "fileViewer.shareUpdateSuccess": "تم تحديث الرابط", + "fileViewer.shareUpdateFailed": "فشل التحديث. لا يزال الإصدار السابق متاحًا.", + "fileViewer.shareUpdateUncertain": "نتيجة التحديث غير معروفة. ربما تغيّر محتوى الرابط العام. تحقّق من الرابط أو أوقف المشاركة قبل إعادة المحاولة.", + "fileViewer.shareUpdateRetry": "إعادة المحاولة", "fileViewer.unpublishFileFailed": "تعذّر إيقاف الرابط. حاول مجددًا.", "fileViewer.publishFileFailed": "تعذّر إنشاء رابط المشاركة. حاول مجددًا لاحقًا.", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3653,6 +3673,8 @@ export const ar: Dict = { "chat.input.viewAll": "عرض الكل", "chat.artifact.export": "تصدير", "chat.artifact.pending": "جارٍ الإنشاء…", + "chat.artifact.shareStatusUnknown": "حالة غير معروفة", + "chat.artifact.published": "تمت المشاركة", "chat.artifact.publish": "مشاركة", "chat.edge.paused": "تم إيقاف المهمة يدويًا", "chat.edge.reconnectCta": "إعادة الاتصال", diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index 74f2d30069..68a1c4bfa5 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -239,6 +239,12 @@ export const de: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "Der Link ist deaktiviert.", 'fileViewer.commentSync.backfillFailedTitle': "Freigabe veröffentlicht", 'fileViewer.commentSync.backfillFailedBody': "Vorhandene Kommentare wurden noch nicht synchronisiert – Besucher sehen sie vorübergehend nicht.", + 'fileViewer.commentSync.backfillTerminalBody': "Einige vorhandene Kommentare konnten nicht mit der Freigabeseite synchronisiert werden und werden nicht automatisch erneut versucht. Der Freigabelink bleibt verfügbar.", + 'fileViewer.commentSync.backfillRetryingBody': "Vorhandene Kommentare wurden noch nicht synchronisiert – Besucher sehen sie vorerst nicht. Im Hintergrund wird automatisch erneut versucht.", + 'fileViewer.commentSync.backfillReopenedPendingBody': "Kommentare für diesen erneut aktivierten Freigabelink werden synchronisiert. Gelöschte oder erledigte Kommentare können auf der Freigabeseite noch sichtbar sein.", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "Die Kommentarsynchronisierung ist noch nicht abgeschlossen. Gelöschte oder erledigte Kommentare können auf der Freigabeseite noch sichtbar sein. Im Hintergrund wird automatisch erneut versucht.", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "Die Kommentarsynchronisierung ist noch nicht abgeschlossen. Gelöschte oder erledigte Kommentare können auf der Freigabeseite noch sichtbar sein. Automatische Wiederholungen wurden eingestellt; der Freigabelink bleibt verfügbar.", + 'fileViewer.commentSync.backfillPendingBody': "Vorhandene Kommentare werden synchronisiert. Besucher sehen sie möglicherweise noch nicht.", 'fileViewer.commentSync.alignFailedBody': "Der Kommentarabgleich ist nicht abgeschlossen – einige Kommentare fehlen möglicherweise.", 'fileViewer.shareGuide.title': "Export abgeschlossen", 'fileViewer.shareGuide.description': "Teile den Link und lade andere ein, ihn anzusehen und zu kommentieren.", @@ -2169,6 +2175,8 @@ export const de: Dict = { 'chat.comments.sending': 'Wird gesendet…', 'chat.comments.edit': 'Bearbeiten', 'chat.comments.select': 'Auswählen', + 'chat.comments.expandBody': 'Mehr anzeigen', + 'chat.comments.collapseBody': 'Weniger anzeigen', 'chat.comments.selectAll': 'Alle auswählen', 'chat.comments.deselect': 'Abwählen', 'chat.comments.nSelected': '{n} ausgewählt', @@ -3079,7 +3087,7 @@ export const de: Dict = { 'fileViewer.tweaks': 'Feinabstimmungen', 'fileViewer.tweaksUnavailable': 'Kein Tweaks-Panel in diesem Artefakt', 'fileViewer.jsxModuleTitle': 'Keine eigenständige Vorschau', - 'fileViewer.jsxModuleBody': 'Diese Datei ist ein Komponentenmodul, das von einer anderen Seite geladen wird.', + 'fileViewer.jsxModuleBody': 'Nur eigenständige HTML-Dateien können geteilt werden. Exportiere diese Komponente zuerst als HTML.', 'fileViewer.jsxModuleCta': 'Öffne die Seite, die es rendert:', 'fileViewer.comment': 'Kommentieren', 'fileViewer.edit': 'Bearbeiten', @@ -3291,7 +3299,19 @@ export const de: Dict = { 'fileViewer.publishFileRequiresTeam': 'Für einen öffentlichen Link wird ein Team-Workspace benötigt. Wechsle zu einem Team-Workspace oder veröffentliche diese Datei über Deploy.', 'fileViewer.publishFileRequiresWorkspace': 'Für einen Freigabelink ist ein angemeldeter Workspace nötig. Melde dich bei OpenDesign Cloud an oder teile die Datei über eine Deploy-Option.', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "Dieses Projekt überschreitet das Freigabelimit von 20 MiB. Verkleinere HTML und referenzierte Ressourcen und versuche es erneut.", + "fileViewer.shareUpdateHint": "Diese Datei enthält neue Änderungen. Aktualisiere den Link für die neueste Version.", + "fileViewer.shareUpdateLink": "Link aktualisieren", + "fileViewer.signInToUpdate": "Anmelden und aktualisieren", + "fileViewer.shareOutdatedSignInHint": "Diese Datei wurde geändert. Melde dich an, um den Link zu aktualisieren oder zu deaktivieren.", + "fileViewer.shareUpdateSuccess": "Link aktualisiert", + "fileViewer.shareUpdateFailed": "Aktualisierung fehlgeschlagen. Die vorherige Version ist weiter verfügbar.", + "fileViewer.shareUpdateUncertain": "Ergebnis der Aktualisierung unbekannt. Der öffentliche Link könnte sich geändert haben. Prüfe den Link oder beende die Freigabe, bevor du es erneut versuchst.", + "fileViewer.shareUpdateRetry": "Erneut versuchen", "fileViewer.unpublishFileFailed": "Der Link konnte nicht deaktiviert werden. Versuche es erneut.", "fileViewer.publishFileFailed": "Freigabelink konnte nicht erstellt werden. Versuche es später erneut.", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3653,6 +3673,8 @@ export const de: Dict = { "chat.input.viewAll": "Alles anzeigen", "chat.artifact.export": "Exportieren", "chat.artifact.pending": "Wird erstellt …", + "chat.artifact.shareStatusUnknown": "Status unbekannt", + "chat.artifact.published": "Geteilt", "chat.artifact.publish": "Teilen", "chat.edge.paused": "Aufgabe manuell pausiert", "chat.edge.reconnectCta": "Erneut verbinden", diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 2c743dfb8c..970cc75b6d 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -239,6 +239,12 @@ export const en: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "The link is disabled.", 'fileViewer.commentSync.backfillFailedTitle': "Share published", 'fileViewer.commentSync.backfillFailedBody': "Existing comments haven't synced yet — visitors can't see them for now.", + 'fileViewer.commentSync.backfillTerminalBody': "Some existing comments could not be synced to the share page and won't be retried automatically. The share link remains available.", + 'fileViewer.commentSync.backfillRetryingBody': "Existing comments haven't synced yet — visitors can't see them for now. Retrying automatically in the background.", + 'fileViewer.commentSync.backfillReopenedPendingBody': "Syncing comments for this reopened link. Previously deleted or resolved comments may still be visible on the share page.", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "Comment sync is incomplete. Previously deleted or resolved comments may still be visible on the share page. Retrying automatically in the background.", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "Comment sync is incomplete. Previously deleted or resolved comments may still be visible on the share page. Automatic retry has stopped; the share link remains available.", + 'fileViewer.commentSync.backfillPendingBody': "Syncing existing comments. Visitors may not see them yet.", 'fileViewer.commentSync.alignFailedBody': "Comment sync isn't fully aligned — some comments may be missing.", 'fileViewer.shareGuide.title': "Export complete", 'fileViewer.shareGuide.description': "Share the link and invite others to view it and leave comments.", @@ -2169,6 +2175,8 @@ export const en: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.expandBody': 'Expand', + 'chat.comments.collapseBody': 'Collapse', 'chat.comments.selectAll': 'Select all', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', @@ -3093,7 +3101,7 @@ export const en: Dict = { 'fileViewer.tweaks': 'Tweaks', 'fileViewer.tweaksUnavailable': 'No tweaks panel in this artifact', 'fileViewer.jsxModuleTitle': 'No standalone preview', - 'fileViewer.jsxModuleBody': 'This file is a component module loaded by another page.', + 'fileViewer.jsxModuleBody': 'Only self-contained HTML files can be shared. Export this component as HTML first.', 'fileViewer.jsxModuleCta': 'Open the page that renders it:', 'fileViewer.comment': 'Comment', 'fileViewer.edit': 'Edit', @@ -3305,7 +3313,19 @@ export const en: Dict = { 'fileViewer.publishFileRequiresTeam': 'Publishing a public link needs a team workspace. Switch to a team workspace, or use Deploy to publish this file.', 'fileViewer.publishFileRequiresWorkspace': 'A share link needs a signed-in workspace. Sign in to OpenDesign Cloud, or use a deploy option to share this file.', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "This project exceeds the 20 MiB sharing limit. Reduce its HTML and referenced resources, then retry.", + "fileViewer.shareUpdateHint": "This file has new changes. Update the link to share the latest version.", + "fileViewer.shareUpdateLink": "Update link", + "fileViewer.signInToUpdate": "Sign in to update", + "fileViewer.shareOutdatedSignInHint": "This file has new changes. Sign in to update or turn off the link.", + "fileViewer.shareUpdateSuccess": "Link updated", + "fileViewer.shareUpdateFailed": "Update failed. The previous version is still available.", + "fileViewer.shareUpdateUncertain": "Update outcome unknown. The live link may have changed. Check the link or stop sharing before retrying.", + "fileViewer.shareUpdateRetry": "Retry", "fileViewer.unpublishFileFailed": "Could not turn off the link. Please try again.", "fileViewer.publishFileFailed": "Could not create the share link. Please try again later.", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3667,6 +3687,8 @@ export const en: Dict = { "chat.input.viewAll": "View all", "chat.artifact.export": "Export", "chat.artifact.pending": "Generating…", + "chat.artifact.shareStatusUnknown": "Status unknown", + "chat.artifact.published": "Shared", "chat.artifact.publish": "Share", "chat.edge.paused": "Task paused manually", "chat.edge.reconnectCta": "Reconnect", diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index a78893f40f..e679bfad90 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -239,6 +239,12 @@ export const esES: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "El enlace está desactivado.", 'fileViewer.commentSync.backfillFailedTitle': "Enlace publicado", 'fileViewer.commentSync.backfillFailedBody': "Los comentarios existentes aún no se han sincronizado; los visitantes no podrán verlos por ahora.", + 'fileViewer.commentSync.backfillTerminalBody': "Algunos comentarios existentes no se pudieron sincronizar con la página compartida y no se volverá a intentar automáticamente. El enlace para compartir sigue disponible.", + 'fileViewer.commentSync.backfillRetryingBody': "Los comentarios existentes aún no se han sincronizado; los visitantes no pueden verlos por ahora. Se volverá a intentar automáticamente en segundo plano.", + 'fileViewer.commentSync.backfillReopenedPendingBody': "Sincronizando los comentarios del enlace compartido reactivado. Los comentarios eliminados o resueltos pueden seguir visibles en la página compartida.", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "La sincronización de comentarios no ha terminado. Los comentarios eliminados o resueltos pueden seguir visibles en la página compartida. Se reintentará automáticamente en segundo plano.", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "La sincronización de comentarios no ha terminado. Los comentarios eliminados o resueltos pueden seguir visibles en la página compartida. Los reintentos automáticos se han detenido; el enlace sigue disponible.", + 'fileViewer.commentSync.backfillPendingBody': "Sincronizando los comentarios existentes. Es posible que los visitantes aún no puedan verlos.", 'fileViewer.commentSync.alignFailedBody': "La alineación de comentarios no se completó; es posible que falten algunos comentarios.", 'fileViewer.shareGuide.title': "Exportación completada", 'fileViewer.shareGuide.description': "Comparte el enlace e invita a otros a verlo y dejar comentarios.", @@ -2169,6 +2175,8 @@ export const esES: Dict = { 'chat.comments.sending': 'Enviando…', 'chat.comments.edit': 'Editar', 'chat.comments.select': 'Seleccionar', + 'chat.comments.expandBody': 'Expandir', + 'chat.comments.collapseBody': 'Contraer', 'chat.comments.selectAll': 'Seleccionar todo', 'chat.comments.deselect': 'Deseleccionar', 'chat.comments.nSelected': '{n} seleccionados', @@ -3079,7 +3087,7 @@ export const esES: Dict = { 'fileViewer.tweaks': 'Ajustes', 'fileViewer.tweaksUnavailable': 'Sin panel de ajustes en este artefacto', 'fileViewer.jsxModuleTitle': 'Sin vista previa independiente', - 'fileViewer.jsxModuleBody': 'Este archivo es un módulo de componente cargado por otra página.', + 'fileViewer.jsxModuleBody': 'Solo se pueden compartir archivos HTML independientes. Exporta primero este componente como HTML.', 'fileViewer.jsxModuleCta': 'Abre la página que lo renderiza:', 'fileViewer.comment': 'Comentar', 'fileViewer.edit': 'Editar', @@ -3291,7 +3299,19 @@ export const esES: Dict = { 'fileViewer.publishFileRequiresTeam': 'Publicar un enlace público requiere un espacio de trabajo de equipo. Cambia a un espacio de equipo o usa Desplegar para publicar este archivo.', 'fileViewer.publishFileRequiresWorkspace': 'Un enlace para compartir requiere un espacio de trabajo con sesión iniciada. Inicia sesión en OpenDesign Cloud o comparte este archivo mediante un despliegue.', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "Este proyecto supera el límite de 20 MiB para compartir. Reduce el HTML y los recursos referenciados e inténtalo de nuevo.", + "fileViewer.shareUpdateHint": "Este archivo tiene cambios nuevos. Actualiza el enlace para compartir la última versión.", + "fileViewer.shareUpdateLink": "Actualizar enlace", + "fileViewer.signInToUpdate": "Iniciar sesión para actualizar", + "fileViewer.shareOutdatedSignInHint": "Este archivo tiene cambios nuevos. Inicia sesión para actualizar o desactivar el enlace.", + "fileViewer.shareUpdateSuccess": "Enlace actualizado", + "fileViewer.shareUpdateFailed": "Error al actualizar. La versión anterior sigue disponible.", + "fileViewer.shareUpdateUncertain": "No se conoce el resultado de la actualización. El enlace público puede haber cambiado. Comprueba el enlace o deja de compartir antes de reintentar.", + "fileViewer.shareUpdateRetry": "Reintentar", "fileViewer.unpublishFileFailed": "No se pudo desactivar el enlace. Inténtalo de nuevo.", "fileViewer.publishFileFailed": "No se pudo crear el enlace para compartir. Inténtalo de nuevo más tarde.", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3653,6 +3673,8 @@ export const esES: Dict = { "chat.input.viewAll": "Ver todo", "chat.artifact.export": "Exportar", "chat.artifact.pending": "Generando…", + "chat.artifact.shareStatusUnknown": "Estado desconocido", + "chat.artifact.published": "Compartido", "chat.artifact.publish": "Compartir", "chat.edge.paused": "Tarea pausada manualmente", "chat.edge.reconnectCta": "Volver a conectar", diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index ede92c9a6c..7d8b27ea0c 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -239,6 +239,12 @@ export const fa: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "پیوند غیرفعال است.", 'fileViewer.commentSync.backfillFailedTitle': "اشتراک منتشر شد", 'fileViewer.commentSync.backfillFailedBody': "نظرات موجود هنوز همگام‌سازی نشده‌اند؛ بازدیدکنندگان فعلاً آن‌ها را نمی‌بینند.", + 'fileViewer.commentSync.backfillTerminalBody': "برخی از نظرات موجود با صفحه اشتراک‌گذاری همگام نشدند و به‌طور خودکار دوباره تلاش نمی‌شود. پیوند اشتراک‌گذاری همچنان در دسترس است.", + 'fileViewer.commentSync.backfillRetryingBody': "نظرات موجود هنوز همگام‌سازی نشده‌اند؛ بازدیدکنندگان فعلاً آن‌ها را نمی‌بینند. تلاش مجدد به‌طور خودکار در پس‌زمینه انجام می‌شود.", + 'fileViewer.commentSync.backfillReopenedPendingBody': "دیدگاه‌های پیوند اشتراک‌گذاری دوباره فعال‌شده در حال همگام‌سازی هستند. دیدگاه‌های حذف‌شده یا حل‌شده ممکن است همچنان در صفحه اشتراک‌گذاری دیده شوند.", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "همگام‌سازی دیدگاه‌ها هنوز کامل نشده است. دیدگاه‌های حذف‌شده یا حل‌شده ممکن است همچنان در صفحه اشتراک‌گذاری دیده شوند. تلاش دوباره به‌طور خودکار در پس‌زمینه انجام می‌شود.", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "همگام‌سازی دیدگاه‌ها هنوز کامل نشده است. دیدگاه‌های حذف‌شده یا حل‌شده ممکن است همچنان در صفحه اشتراک‌گذاری دیده شوند. تلاش خودکار متوقف شده است؛ پیوند اشتراک‌گذاری همچنان در دسترس است.", + 'fileViewer.commentSync.backfillPendingBody': "نظرات موجود در حال همگام‌سازی هستند. بازدیدکنندگان ممکن است هنوز آن‌ها را نبینند.", 'fileViewer.commentSync.alignFailedBody': "همسان‌سازی نظرات کامل نشده است؛ ممکن است برخی نظرات نمایش داده نشوند.", 'fileViewer.shareGuide.title': "خروجی گرفته شد", 'fileViewer.shareGuide.description': "پیوند را به اشتراک بگذارید و دیگران را برای مشاهده و ثبت نظر دعوت کنید.", @@ -2169,6 +2175,8 @@ export const fa: Dict = { 'chat.comments.sending': 'در حال ارسال…', 'chat.comments.edit': 'ویرایش', 'chat.comments.select': 'انتخاب', + 'chat.comments.expandBody': 'نمایش بیشتر', + 'chat.comments.collapseBody': 'نمایش کمتر', 'chat.comments.selectAll': 'انتخاب همه', 'chat.comments.deselect': 'لغو انتخاب', 'chat.comments.nSelected': '{n} مورد انتخاب‌شده', @@ -3079,7 +3087,7 @@ export const fa: Dict = { 'fileViewer.tweaks': 'تنظیمات جزئی', 'fileViewer.tweaksUnavailable': 'پنل تنظیمات در این مصنوع وجود ندارد', 'fileViewer.jsxModuleTitle': 'پیش‌نمایش مستقلی وجود ندارد', - 'fileViewer.jsxModuleBody': 'این فایل یک ماژول کامپوننت است که توسط صفحه‌ای دیگر بارگذاری می‌شود.', + 'fileViewer.jsxModuleBody': 'فقط فایل‌های HTML مستقل قابل اشتراک‌گذاری هستند. ابتدا این مؤلفه را به‌صورت HTML خروجی بگیرید.', 'fileViewer.jsxModuleCta': 'صفحه‌ای را که آن را نمایش می‌دهد باز کنید:', 'fileViewer.comment': 'نظر', 'fileViewer.edit': 'ویرایش', @@ -3291,7 +3299,19 @@ export const fa: Dict = { 'fileViewer.publishFileRequiresTeam': 'انتشار پیوند عمومی به فضای کاری تیمی نیاز دارد. به یک فضای کاری تیمی بروید یا این فایل را با «استقرار» منتشر کنید.', 'fileViewer.publishFileRequiresWorkspace': 'ایجاد پیوند اشتراک‌گذاری نیازمند ورود به فضای کاری است. به OpenDesign Cloud وارد شوید یا این فایل را از طریق استقرار به اشتراک بگذارید.', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "این پروژه از سقف اشتراک‌گذاری 20 MiB بیشتر است. حجم HTML و منابع ارجاع‌شده را کاهش دهید و دوباره تلاش کنید.", + "fileViewer.shareUpdateHint": "این فایل تغییرات جدیدی دارد. پیوند را برای اشتراک‌گذاری آخرین نسخه به‌روزرسانی کنید.", + "fileViewer.shareUpdateLink": "به‌روزرسانی پیوند", + "fileViewer.signInToUpdate": "ورود برای به‌روزرسانی", + "fileViewer.shareOutdatedSignInHint": "این فایل تغییرات جدیدی دارد. برای به‌روزرسانی یا غیرفعال کردن پیوند وارد شوید.", + "fileViewer.shareUpdateSuccess": "پیوند به‌روزرسانی شد", + "fileViewer.shareUpdateFailed": "به‌روزرسانی ناموفق بود. نسخه قبلی همچنان در دسترس است.", + "fileViewer.shareUpdateUncertain": "نتیجه به‌روزرسانی نامشخص است. محتوای پیوند عمومی ممکن است تغییر کرده باشد. پیش از تلاش دوباره پیوند را بررسی کنید یا اشتراک‌گذاری را متوقف کنید.", + "fileViewer.shareUpdateRetry": "تلاش مجدد", "fileViewer.unpublishFileFailed": "غیرفعال کردن پیوند ناموفق بود. لطفاً دوباره تلاش کنید.", "fileViewer.publishFileFailed": "ایجاد پیوند اشتراک‌گذاری ناموفق بود. لطفاً بعداً دوباره تلاش کنید.", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3653,6 +3673,8 @@ export const fa: Dict = { "chat.input.viewAll": "نمایش همه", "chat.artifact.export": "خروجی گرفتن", "chat.artifact.pending": "در حال ساخت…", + "chat.artifact.shareStatusUnknown": "وضعیت نامشخص", + "chat.artifact.published": "به‌اشتراک‌گذاشته‌شده", "chat.artifact.publish": "اشتراک‌گذاری", "chat.edge.paused": "وظیفه به صورت دستی متوقف شد", "chat.edge.reconnectCta": "اتصال مجدد", diff --git a/apps/web/src/i18n/locales/fr.ts b/apps/web/src/i18n/locales/fr.ts index 0f98cdc8df..3e77dbe934 100644 --- a/apps/web/src/i18n/locales/fr.ts +++ b/apps/web/src/i18n/locales/fr.ts @@ -239,6 +239,12 @@ export const fr: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "Le lien est désactivé.", 'fileViewer.commentSync.backfillFailedTitle': "Partage publié", 'fileViewer.commentSync.backfillFailedBody': "Les commentaires existants ne sont pas encore synchronisés ; les visiteurs ne les voient pas pour le moment.", + 'fileViewer.commentSync.backfillTerminalBody': "Certains commentaires existants n'ont pas pu être synchronisés avec la page partagée et ne seront pas réessayés automatiquement. Le lien de partage reste disponible.", + 'fileViewer.commentSync.backfillRetryingBody': "Les commentaires existants ne sont pas encore synchronisés ; les visiteurs ne les voient pas pour le moment. Nouvelle tentative automatique en arrière-plan.", + 'fileViewer.commentSync.backfillReopenedPendingBody': "Synchronisation des commentaires de ce lien de partage réactivé. Des commentaires supprimés ou résolus peuvent encore apparaître sur la page partagée.", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "La synchronisation des commentaires n’est pas terminée. Des commentaires supprimés ou résolus peuvent encore apparaître sur la page partagée. Une nouvelle tentative est effectuée automatiquement en arrière-plan.", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "La synchronisation des commentaires n’est pas terminée. Des commentaires supprimés ou résolus peuvent encore apparaître sur la page partagée. Les tentatives automatiques ont cessé ; le lien de partage reste disponible.", + 'fileViewer.commentSync.backfillPendingBody': "Synchronisation des commentaires existants. Les visiteurs ne les voient peut-être pas encore.", 'fileViewer.commentSync.alignFailedBody': "L'alignement des commentaires est incomplet ; certains commentaires pourraient être absents.", 'fileViewer.shareGuide.title': "Export terminé", 'fileViewer.shareGuide.description': "Partagez le lien et invitez les autres à le consulter et à laisser des commentaires.", @@ -2161,6 +2167,8 @@ export const fr: Dict = { 'chat.comments.sending': 'Envoi…', 'chat.comments.edit': 'Modifier', 'chat.comments.select': 'Sélectionner', + 'chat.comments.expandBody': 'Développer', + 'chat.comments.collapseBody': 'Réduire', 'chat.comments.selectAll': 'Tout sélectionner', 'chat.comments.deselect': 'Désélectionner', 'chat.comments.nSelected': '{n} sélectionné(s)', @@ -3071,7 +3079,7 @@ export const fr: Dict = { 'fileViewer.tweaks': 'Ajustements', 'fileViewer.tweaksUnavailable': 'Aucun panneau d\'ajustements dans cet artefact', 'fileViewer.jsxModuleTitle': 'Aucun aperçu autonome', - 'fileViewer.jsxModuleBody': 'Ce fichier est un module de composant chargé par une autre page.', + 'fileViewer.jsxModuleBody': 'Seuls les fichiers HTML autonomes peuvent être partagés. Exportez d’abord ce composant au format HTML.', 'fileViewer.jsxModuleCta': 'Ouvrez la page qui le rend :', 'fileViewer.comment': 'Commenter', 'fileViewer.edit': 'Modifier', @@ -3283,7 +3291,19 @@ export const fr: Dict = { 'fileViewer.publishFileRequiresTeam': 'La publication d’un lien public nécessite un espace de travail d’équipe. Passez à un espace d’équipe ou utilisez Déployer pour publier ce fichier.', 'fileViewer.publishFileRequiresWorkspace': 'Un lien de partage nécessite un espace de travail connecté. Connectez-vous à OpenDesign Cloud ou partagez ce fichier via un déploiement.', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "Ce projet dépasse la limite de partage de 20 MiB. Réduisez le HTML et les ressources référencées, puis réessayez.", + "fileViewer.shareUpdateHint": "Ce fichier a changé. Mettez le lien à jour pour partager la dernière version.", + "fileViewer.shareUpdateLink": "Mettre à jour le lien", + "fileViewer.signInToUpdate": "Se connecter pour mettre à jour", + "fileViewer.shareOutdatedSignInHint": "Ce fichier a changé. Connectez-vous pour mettre à jour ou désactiver le lien.", + "fileViewer.shareUpdateSuccess": "Lien mis à jour", + "fileViewer.shareUpdateFailed": "Échec de la mise à jour. La version précédente reste disponible.", + "fileViewer.shareUpdateUncertain": "Résultat de la mise à jour inconnu. Le lien public a peut-être changé. Vérifiez le lien ou arrêtez le partage avant de réessayer.", + "fileViewer.shareUpdateRetry": "Réessayer", "fileViewer.unpublishFileFailed": "Impossible de désactiver le lien. Réessayez.", "fileViewer.publishFileFailed": "Impossible de créer le lien de partage. Réessayez plus tard.", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3645,6 +3665,8 @@ export const fr: Dict = { "chat.input.viewAll": "Tout afficher", "chat.artifact.export": "Exporter", "chat.artifact.pending": "Génération…", + "chat.artifact.shareStatusUnknown": "État inconnu", + "chat.artifact.published": "Partagé", "chat.artifact.publish": "Partager", "chat.edge.paused": "Tâche mise en pause manuellement", "chat.edge.reconnectCta": "Se reconnecter", diff --git a/apps/web/src/i18n/locales/hu.ts b/apps/web/src/i18n/locales/hu.ts index d8bd3e732c..1b38cbd009 100644 --- a/apps/web/src/i18n/locales/hu.ts +++ b/apps/web/src/i18n/locales/hu.ts @@ -239,6 +239,12 @@ export const hu: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "A hivatkozás le van tiltva.", 'fileViewer.commentSync.backfillFailedTitle': "A megosztás közzétéve", 'fileViewer.commentSync.backfillFailedBody': "A meglévő hozzászólások még nem szinkronizálódtak – a látogatók egyelőre nem látják őket.", + 'fileViewer.commentSync.backfillTerminalBody': "Néhány meglévő hozzászólást nem sikerült szinkronizálni a megosztási oldallal, és a rendszer nem próbálkozik újra automatikusan. A megosztási link továbbra is elérhető.", + 'fileViewer.commentSync.backfillRetryingBody': "A meglévő hozzászólások még nem szinkronizálódtak, ezért a látogatók egyelőre nem látják őket. A rendszer a háttérben automatikusan újrapróbálkozik.", + 'fileViewer.commentSync.backfillReopenedPendingBody': "Az újra aktivált megosztási hivatkozás hozzászólásainak szinkronizálása folyamatban van. A törölt vagy megoldott hozzászólások még láthatók lehetnek a megosztott oldalon.", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "A hozzászólások szinkronizálása még nem fejeződött be. A törölt vagy megoldott hozzászólások továbbra is láthatók lehetnek a megosztott oldalon. A háttérben automatikus újrapróbálkozás zajlik.", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "A hozzászólások szinkronizálása még nem fejeződött be. A törölt vagy megoldott hozzászólások továbbra is láthatók lehetnek a megosztott oldalon. Az automatikus újrapróbálkozás leállt; a megosztási hivatkozás továbbra is elérhető.", + 'fileViewer.commentSync.backfillPendingBody': "A meglévő hozzászólások szinkronizálása folyamatban van. A látogatók egyelőre nem biztos, hogy látják őket.", 'fileViewer.commentSync.alignFailedBody': "A hozzászólások egyeztetése nem fejeződött be – előfordulhat, hogy néhány hozzászólás hiányzik.", 'fileViewer.shareGuide.title': "Az exportálás befejeződött", 'fileViewer.shareGuide.description': "Oszd meg a linket, és hívj meg másokat megtekintésre és megjegyzések írására.", @@ -2161,6 +2167,8 @@ export const hu: Dict = { 'chat.comments.sending': 'Küldés…', 'chat.comments.edit': 'Szerkesztés', 'chat.comments.select': 'Kijelölés', + 'chat.comments.expandBody': 'Kibontás', + 'chat.comments.collapseBody': 'Összecsukás', 'chat.comments.selectAll': 'Összes kijelölése', 'chat.comments.deselect': 'Kijelölés megszüntetése', 'chat.comments.nSelected': '{n} kijelölve', @@ -3071,7 +3079,7 @@ export const hu: Dict = { 'fileViewer.tweaks': 'Finomhangolás', 'fileViewer.tweaksUnavailable': 'Nincs finomhangolási panel ebben az artefaktumban', 'fileViewer.jsxModuleTitle': 'Nincs önálló előnézet', - 'fileViewer.jsxModuleBody': 'Ez a fájl egy komponensmodul, amelyet egy másik oldal tölt be.', + 'fileViewer.jsxModuleBody': 'Csak az önálló HTML-fájlok oszthatók meg. Előbb exportáld ezt a komponenst HTML-ként.', 'fileViewer.jsxModuleCta': 'Nyisd meg az oldalt, amely megjeleníti:', 'fileViewer.comment': 'Megjegyzés', 'fileViewer.edit': 'Szerkesztés', @@ -3283,7 +3291,19 @@ export const hu: Dict = { 'fileViewer.publishFileRequiresTeam': 'A nyilvános hivatkozás közzétételéhez csapatmunkaterület szükséges. Válts csapatmunkaterületre, vagy tedd közzé a fájlt a Közzététel funkcióval.', 'fileViewer.publishFileRequiresWorkspace': 'A megosztási linkhez bejelentkezett munkaterület szükséges. Jelentkezz be az OpenDesign Cloudba, vagy oszd meg a fájlt telepítéssel.', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "A projekt meghaladja a 20 MiB megosztási korlátot. Csökkentsd a HTML és a hivatkozott erőforrások méretét, majd próbáld újra.", + "fileViewer.shareUpdateHint": "A fájl megváltozott. Frissítsd a hivatkozást a legújabb verzió megosztásához.", + "fileViewer.shareUpdateLink": "Hivatkozás frissítése", + "fileViewer.signInToUpdate": "Bejelentkezés a frissítéshez", + "fileViewer.shareOutdatedSignInHint": "A fájl megváltozott. Jelentkezz be a hivatkozás frissítéséhez vagy kikapcsolásához.", + "fileViewer.shareUpdateSuccess": "Hivatkozás frissítve", + "fileViewer.shareUpdateFailed": "A frissítés sikertelen. A korábbi verzió továbbra is elérhető.", + "fileViewer.shareUpdateUncertain": "A frissítés eredménye ismeretlen. A nyilvános link tartalma megváltozhatott. Újrapróbálás előtt ellenőrizd a linket vagy állítsd le a megosztást.", + "fileViewer.shareUpdateRetry": "Újra", "fileViewer.unpublishFileFailed": "Nem sikerült kikapcsolni a linket. Próbáld újra.", "fileViewer.publishFileFailed": "Nem sikerült létrehozni a megosztási linket. Próbáld újra később.", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3645,6 +3665,8 @@ export const hu: Dict = { "chat.input.viewAll": "Összes megtekintése", "chat.artifact.export": "Exportálás", "chat.artifact.pending": "Generálás…", + "chat.artifact.shareStatusUnknown": "Ismeretlen állapot", + "chat.artifact.published": "Megosztva", "chat.artifact.publish": "Megosztás", "chat.edge.paused": "A feladat manuálisan szüneteltetve", "chat.edge.reconnectCta": "Újracsatlakozás", diff --git a/apps/web/src/i18n/locales/id.ts b/apps/web/src/i18n/locales/id.ts index c81a9f2bfa..498f358b69 100644 --- a/apps/web/src/i18n/locales/id.ts +++ b/apps/web/src/i18n/locales/id.ts @@ -239,6 +239,12 @@ export const id: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "Tautan dinonaktifkan.", 'fileViewer.commentSync.backfillFailedTitle': "Tautan berbagi dipublikasikan", 'fileViewer.commentSync.backfillFailedBody': "Komentar yang sudah ada belum tersinkron; pengunjung untuk sementara belum bisa melihatnya.", + 'fileViewer.commentSync.backfillTerminalBody': "Beberapa komentar yang sudah ada tidak dapat disinkronkan ke halaman berbagi dan tidak akan dicoba ulang secara otomatis. Tautan berbagi tetap tersedia.", + 'fileViewer.commentSync.backfillRetryingBody': "Komentar yang sudah ada belum tersinkron; pengunjung belum dapat melihatnya. Mencoba kembali otomatis di latar belakang.", + 'fileViewer.commentSync.backfillReopenedPendingBody': "Menyinkronkan komentar untuk tautan berbagi yang diaktifkan kembali. Komentar yang dihapus atau diselesaikan mungkin masih terlihat di halaman berbagi.", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "Sinkronisasi komentar belum selesai. Komentar yang sudah dihapus atau diselesaikan mungkin masih terlihat di halaman yang dibagikan. Sistem mencoba lagi secara otomatis di latar belakang.", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "Sinkronisasi komentar belum selesai. Komentar yang sudah dihapus atau diselesaikan mungkin masih terlihat di halaman yang dibagikan. Percobaan ulang otomatis telah berhenti; tautan berbagi tetap tersedia.", + 'fileViewer.commentSync.backfillPendingBody': "Komentar yang sudah ada sedang disinkronkan. Pengunjung mungkin belum dapat melihatnya.", 'fileViewer.commentSync.alignFailedBody': "Penyelarasan komentar belum selesai; beberapa komentar mungkin tidak muncul.", 'fileViewer.shareGuide.title': "Ekspor selesai", 'fileViewer.shareGuide.description': "Bagikan tautan dan ajak orang lain untuk melihat serta memberikan komentar.", @@ -2161,6 +2167,8 @@ export const id: Dict = { 'chat.comments.sending': 'Sending…', 'chat.comments.edit': 'Edit', 'chat.comments.select': 'Select', + 'chat.comments.expandBody': 'Perluas', + 'chat.comments.collapseBody': 'Ciutkan', 'chat.comments.selectAll': 'Pilih semua', 'chat.comments.deselect': 'Deselect', 'chat.comments.nSelected': '{n} selected', @@ -3071,7 +3079,7 @@ export const id: Dict = { 'fileViewer.tweaks': 'Tweaks', 'fileViewer.tweaksUnavailable': 'Tidak ada panel tweaks pada artefak ini', 'fileViewer.jsxModuleTitle': 'Tidak ada pratinjau mandiri', - 'fileViewer.jsxModuleBody': 'File ini adalah modul komponen yang dimuat oleh halaman lain.', + 'fileViewer.jsxModuleBody': 'Hanya file HTML mandiri yang dapat dibagikan. Ekspor komponen ini sebagai HTML terlebih dahulu.', 'fileViewer.jsxModuleCta': 'Buka halaman yang merendernya:', 'fileViewer.comment': 'Komentar', 'fileViewer.edit': 'Edit', @@ -3283,7 +3291,19 @@ export const id: Dict = { 'fileViewer.publishFileRequiresTeam': 'Menerbitkan tautan publik memerlukan ruang kerja tim. Beralihlah ke ruang kerja tim, atau gunakan Deploy untuk menerbitkan berkas ini.', 'fileViewer.publishFileRequiresWorkspace': 'Tautan berbagi memerlukan workspace yang masuk. Masuk ke OpenDesign Cloud atau bagikan file ini lewat deploy.', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "Proyek ini melebihi batas berbagi 20 MiB. Kurangi ukuran HTML dan sumber daya yang dirujuk, lalu coba lagi.", + "fileViewer.shareUpdateHint": "Berkas ini memiliki perubahan baru. Perbarui tautan untuk membagikan versi terbaru.", + "fileViewer.shareUpdateLink": "Perbarui tautan", + "fileViewer.signInToUpdate": "Masuk untuk memperbarui", + "fileViewer.shareOutdatedSignInHint": "File ini memiliki perubahan baru. Masuk untuk memperbarui atau menonaktifkan tautan.", + "fileViewer.shareUpdateSuccess": "Tautan diperbarui", + "fileViewer.shareUpdateFailed": "Pembaruan gagal. Versi sebelumnya masih tersedia.", + "fileViewer.shareUpdateUncertain": "Hasil pembaruan tidak diketahui. Konten tautan publik mungkin berubah. Periksa tautan atau hentikan berbagi sebelum mencoba lagi.", + "fileViewer.shareUpdateRetry": "Coba lagi", "fileViewer.unpublishFileFailed": "Tidak dapat menonaktifkan tautan. Silakan coba lagi.", "fileViewer.publishFileFailed": "Tidak dapat membuat tautan berbagi. Silakan coba lagi nanti.", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3645,6 +3665,8 @@ export const id: Dict = { "chat.input.viewAll": "Lihat semua", "chat.artifact.export": "Export", "chat.artifact.pending": "Membuat…", + "chat.artifact.shareStatusUnknown": "Status tidak diketahui", + "chat.artifact.published": "Dibagikan", "chat.artifact.publish": "Bagikan", "chat.edge.paused": "Tugas dijeda secara manual", "chat.edge.reconnectCta": "Sambungkan ulang", diff --git a/apps/web/src/i18n/locales/it.ts b/apps/web/src/i18n/locales/it.ts index ab746dab57..1125578b09 100644 --- a/apps/web/src/i18n/locales/it.ts +++ b/apps/web/src/i18n/locales/it.ts @@ -239,6 +239,12 @@ export const it: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "Il link è disattivato.", 'fileViewer.commentSync.backfillFailedTitle': "Condivisione pubblicata", 'fileViewer.commentSync.backfillFailedBody': "I commenti esistenti non sono ancora sincronizzati; per ora i visitatori non li vedono.", + 'fileViewer.commentSync.backfillTerminalBody': "Alcuni commenti esistenti non sono stati sincronizzati con la pagina condivisa e non verranno ritentati automaticamente. Il link di condivisione resta disponibile.", + 'fileViewer.commentSync.backfillRetryingBody': "I commenti esistenti non sono ancora sincronizzati; per ora i visitatori non li vedono. Nuovo tentativo automatico in background.", + 'fileViewer.commentSync.backfillReopenedPendingBody': "Sincronizzazione dei commenti del link condiviso riattivato. I commenti eliminati o risolti potrebbero essere ancora visibili nella pagina condivisa.", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "La sincronizzazione dei commenti non è ancora terminata. I commenti eliminati o risolti potrebbero essere ancora visibili nella pagina condivisa. Nuovo tentativo automatico in background.", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "La sincronizzazione dei commenti non è ancora terminata. I commenti eliminati o risolti potrebbero essere ancora visibili nella pagina condivisa. I tentativi automatici sono terminati; il link resta disponibile.", + 'fileViewer.commentSync.backfillPendingBody': "Sincronizzazione dei commenti esistenti in corso. I visitatori potrebbero non vederli ancora.", 'fileViewer.commentSync.alignFailedBody': "L'allineamento dei commenti non è completo; alcuni commenti potrebbero mancare.", 'fileViewer.shareGuide.title': "Esportazione completata", 'fileViewer.shareGuide.description': "Condividi il link e invita altri a visualizzarlo e lasciare commenti.", @@ -2161,6 +2167,8 @@ export const it: Dict = { 'chat.comments.sending': 'Invio…', 'chat.comments.edit': 'Modifica', 'chat.comments.select': 'Seleziona', + 'chat.comments.expandBody': 'Espandi', + 'chat.comments.collapseBody': 'Comprimi', 'chat.comments.selectAll': 'Seleziona tutto', 'chat.comments.deselect': 'Deseleziona', 'chat.comments.nSelected': '{n} selezionati', @@ -3071,7 +3079,7 @@ export const it: Dict = { 'fileViewer.tweaks': 'Modifiche', 'fileViewer.tweaksUnavailable': 'Nessun pannello di modifiche in questo artefatto', 'fileViewer.jsxModuleTitle': 'Nessuna anteprima autonoma', - 'fileViewer.jsxModuleBody': 'Questo file è un modulo componente caricato da un’altra pagina.', + 'fileViewer.jsxModuleBody': 'È possibile condividere solo file HTML autonomi. Esporta prima questo componente come HTML.', 'fileViewer.jsxModuleCta': 'Apri la pagina che lo visualizza:', 'fileViewer.comment': 'Commenta', 'fileViewer.edit': 'Modifica', @@ -3283,7 +3291,19 @@ export const it: Dict = { 'fileViewer.publishFileRequiresTeam': 'La pubblicazione di un link pubblico richiede uno spazio di lavoro del team. Passa a uno spazio del team o usa Distribuisci per pubblicare questo file.', 'fileViewer.publishFileRequiresWorkspace': 'Un link di condivisione richiede uno spazio di lavoro con accesso effettuato. Accedi a OpenDesign Cloud oppure condividi questo file tramite un deploy.', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "Questo progetto supera il limite di condivisione di 20 MiB. Riduci HTML e risorse referenziate, poi riprova.", + "fileViewer.shareUpdateHint": "Questo file contiene nuove modifiche. Aggiorna il link per condividere la versione più recente.", + "fileViewer.shareUpdateLink": "Aggiorna link", + "fileViewer.signInToUpdate": "Accedi per aggiornare", + "fileViewer.shareOutdatedSignInHint": "Questo file ha nuove modifiche. Accedi per aggiornare o disattivare il link.", + "fileViewer.shareUpdateSuccess": "Link aggiornato", + "fileViewer.shareUpdateFailed": "Aggiornamento non riuscito. La versione precedente è ancora disponibile.", + "fileViewer.shareUpdateUncertain": "Esito dell’aggiornamento sconosciuto. Il link pubblico potrebbe essere cambiato. Controlla il link o interrompi la condivisione prima di riprovare.", + "fileViewer.shareUpdateRetry": "Riprova", "fileViewer.unpublishFileFailed": "Impossibile disattivare il link. Riprova.", "fileViewer.publishFileFailed": "Impossibile creare il link di condivisione. Riprova più tardi.", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3645,6 +3665,8 @@ export const it: Dict = { "chat.input.viewAll": "Mostra tutto", "chat.artifact.export": "Esporta", "chat.artifact.pending": "Generazione…", + "chat.artifact.shareStatusUnknown": "Stato sconosciuto", + "chat.artifact.published": "Condiviso", "chat.artifact.publish": "Condividi", "chat.edge.paused": "Attività messa in pausa manualmente", "chat.edge.reconnectCta": "Riconnetti", diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index 6d7b5cb250..337634d41a 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -239,6 +239,12 @@ export const ja: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "リンクは無効です。", 'fileViewer.commentSync.backfillFailedTitle': "共有を公開しました", 'fileViewer.commentSync.backfillFailedBody': "既存のコメントはまだ同期されていません。訪問者には一時的に表示されません。", + 'fileViewer.commentSync.backfillTerminalBody': "一部の既存コメントを共有ページに同期できず、自動再試行は行われません。共有リンクは引き続き利用できます。", + 'fileViewer.commentSync.backfillRetryingBody': "既存のコメントはまだ同期されていないため、訪問者には一時的に表示されません。バックグラウンドで自動的に再試行しています。", + 'fileViewer.commentSync.backfillReopenedPendingBody': "再開した共有リンクのコメントを同期しています。削除済みまたは解決済みのコメントが共有ページに残っている場合があります。", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "コメントの同期はまだ完了していません。共有ページに削除済みまたは解決済みのコメントが残っている場合があります。バックグラウンドで自動的に再試行しています。", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "コメントの同期はまだ完了していません。共有ページに削除済みまたは解決済みのコメントが残っている場合があります。自動再試行は停止しましたが、共有リンクは利用できます。", + 'fileViewer.commentSync.backfillPendingBody': "既存のコメントを同期中です。訪問者にはまだ表示されない場合があります。", 'fileViewer.commentSync.alignFailedBody': "コメントの整合が完了していません。一部のコメントが表示されない可能性があります。", 'fileViewer.shareGuide.title': "エクスポート完了", 'fileViewer.shareGuide.description': "リンクを共有して、みんなに閲覧やコメントを依頼しましょう。", @@ -2161,6 +2167,8 @@ export const ja: Dict = { 'chat.comments.sending': '送信中…', 'chat.comments.edit': '編集', 'chat.comments.select': '選択', + 'chat.comments.expandBody': '展開', + 'chat.comments.collapseBody': '折りたたむ', 'chat.comments.selectAll': 'すべて選択', 'chat.comments.deselect': '選択解除', 'chat.comments.nSelected': '{n} 件選択中', @@ -3071,7 +3079,7 @@ export const ja: Dict = { 'fileViewer.tweaks': '調整', 'fileViewer.tweaksUnavailable': 'このアーティファクトには調整パネルがありません', 'fileViewer.jsxModuleTitle': '単独プレビューはありません', - 'fileViewer.jsxModuleBody': 'このファイルは別のページから読み込まれるコンポーネントモジュールです。', + 'fileViewer.jsxModuleBody': '共有できるのは単体で動作するHTMLファイルのみです。先にこのコンポーネントをHTMLとして書き出してください。', 'fileViewer.jsxModuleCta': '表示するページを開いてください:', 'fileViewer.comment': 'コメント', 'fileViewer.edit': '編集', @@ -3283,7 +3291,19 @@ export const ja: Dict = { 'fileViewer.publishFileRequiresTeam': '公開リンクの発行にはチームワークスペースが必要です。チームワークスペースに切り替えるか、デプロイでこのファイルを公開してください。', 'fileViewer.publishFileRequiresWorkspace': '共有リンクの作成にはサインイン済みのワークスペースが必要です。OpenDesign Cloud にサインインするか、デプロイでこのファイルを共有してください。', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "プロジェクトが共有上限の 20 MiB を超えています。HTML と参照リソースの合計サイズを減らしてから再試行してください。", + "fileViewer.shareUpdateHint": "このファイルに新しい変更があります。リンクを更新して最新版を共有してください。", + "fileViewer.shareUpdateLink": "リンクを更新", + "fileViewer.signInToUpdate": "ログインして更新", + "fileViewer.shareOutdatedSignInHint": "ファイルに新しい変更があります。ログインするとリンクを更新または停止できます。", + "fileViewer.shareUpdateSuccess": "リンクを更新しました", + "fileViewer.shareUpdateFailed": "更新に失敗しました。前のバージョンは引き続き利用できます。", + "fileViewer.shareUpdateUncertain": "更新結果は不明です。公開リンクの内容が変わった可能性があります。再試行前にリンクを確認するか共有を停止してください。", + "fileViewer.shareUpdateRetry": "再試行", "fileViewer.unpublishFileFailed": "リンクを無効にできませんでした。もう一度お試しください。", "fileViewer.publishFileFailed": "共有リンクを作成できませんでした。しばらくしてから再試行してください。", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3645,6 +3665,8 @@ export const ja: Dict = { "chat.input.viewAll": "すべて表示", "chat.artifact.export": "エクスポート", "chat.artifact.pending": "生成中…", + "chat.artifact.shareStatusUnknown": "状態不明", + "chat.artifact.published": "共有済み", "chat.artifact.publish": "共有", "chat.edge.paused": "タスクを手動で一時停止しました", "chat.edge.reconnectCta": "再接続", diff --git a/apps/web/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts index 7d886d696e..1dacfa84d0 100644 --- a/apps/web/src/i18n/locales/ko.ts +++ b/apps/web/src/i18n/locales/ko.ts @@ -239,6 +239,12 @@ export const ko: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "링크가 비활성화되었습니다.", 'fileViewer.commentSync.backfillFailedTitle': "공유가 게시되었습니다", 'fileViewer.commentSync.backfillFailedBody': "기존 댓글이 아직 동기화되지 않았습니다. 방문자에게는 잠시 표시되지 않습니다.", + 'fileViewer.commentSync.backfillTerminalBody': "일부 기존 댓글을 공유 페이지와 동기화하지 못했으며 자동으로 다시 시도하지 않습니다. 공유 링크는 계속 사용할 수 있습니다.", + 'fileViewer.commentSync.backfillRetryingBody': "기존 댓글이 아직 동기화되지 않아 방문자에게 표시되지 않습니다. 백그라운드에서 자동으로 다시 시도하고 있습니다.", + 'fileViewer.commentSync.backfillReopenedPendingBody': "다시 활성화한 공유 링크의 댓글을 동기화하고 있습니다. 삭제되거나 해결된 댓글이 공유 페이지에 여전히 표시될 수 있습니다.", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "댓글 동기화가 완료되지 않았습니다. 공유 페이지에 삭제되거나 해결된 댓글이 여전히 표시될 수 있습니다. 백그라운드에서 자동으로 다시 시도합니다.", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "댓글 동기화가 완료되지 않았습니다. 공유 페이지에 삭제되거나 해결된 댓글이 여전히 표시될 수 있습니다. 자동 재시도가 중단되었지만 공유 링크는 계속 사용할 수 있습니다.", + 'fileViewer.commentSync.backfillPendingBody': "기존 댓글을 동기화하고 있습니다. 방문자에게 아직 표시되지 않을 수 있습니다.", 'fileViewer.commentSync.alignFailedBody': "댓글 정렬이 완료되지 않았습니다. 일부 댓글이 표시되지 않을 수 있습니다.", 'fileViewer.shareGuide.title': "내보내기 완료", 'fileViewer.shareGuide.description': "링크를 공유하고 다른 사람에게 보기와 댓글 작성을 요청하세요.", @@ -2161,6 +2167,8 @@ export const ko: Dict = { 'chat.comments.sending': '전송 중…', 'chat.comments.edit': '편집', 'chat.comments.select': '선택', + 'chat.comments.expandBody': '펼치기', + 'chat.comments.collapseBody': '접기', 'chat.comments.selectAll': '모두 선택', 'chat.comments.deselect': '선택 해제', 'chat.comments.nSelected': '{n}개 선택됨', @@ -3071,7 +3079,7 @@ export const ko: Dict = { 'fileViewer.tweaks': '조정 (Tweaks)', 'fileViewer.tweaksUnavailable': '이 아티팩트에 조정 패널이 없습니다', 'fileViewer.jsxModuleTitle': '독립 미리보기 없음', - 'fileViewer.jsxModuleBody': '이 파일은 다른 페이지에서 불러오는 컴포넌트 모듈입니다.', + 'fileViewer.jsxModuleBody': '독립 실행형 HTML 파일만 공유할 수 있습니다. 먼저 이 구성 요소를 HTML로 내보내세요.', 'fileViewer.jsxModuleCta': '이 파일을 렌더링하는 페이지를 여세요:', 'fileViewer.comment': '댓글', 'fileViewer.edit': '편집', @@ -3283,7 +3291,19 @@ export const ko: Dict = { 'fileViewer.publishFileRequiresTeam': '공개 링크를 게시하려면 팀 워크스페이스가 필요합니다. 팀 워크스페이스로 전환하거나 배포를 사용해 이 파일을 게시하세요.', 'fileViewer.publishFileRequiresWorkspace': '공유 링크를 만들려면 로그인된 워크스페이스가 필요합니다. OpenDesign Cloud에 로그인하거나 배포로 이 파일을 공유하세요.', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "프로젝트가 공유 한도인 20 MiB를 초과합니다. HTML 및 참조 리소스의 크기를 줄인 후 다시 시도하세요.", + "fileViewer.shareUpdateHint": "이 파일에 새 변경 사항이 있습니다. 링크를 업데이트하여 최신 버전을 공유하세요.", + "fileViewer.shareUpdateLink": "링크 업데이트", + "fileViewer.signInToUpdate": "로그인 후 업데이트", + "fileViewer.shareOutdatedSignInHint": "파일에 새 변경 사항이 있습니다. 로그인한 후 링크를 업데이트하거나 비활성화할 수 있습니다.", + "fileViewer.shareUpdateSuccess": "링크가 업데이트됨", + "fileViewer.shareUpdateFailed": "업데이트 실패. 이전 버전은 계속 사용할 수 있습니다.", + "fileViewer.shareUpdateUncertain": "업데이트 결과를 확인할 수 없습니다. 공개 링크 내용이 변경되었을 수 있습니다. 다시 시도하기 전에 링크를 확인하거나 공유를 중지하세요.", + "fileViewer.shareUpdateRetry": "다시 시도", "fileViewer.unpublishFileFailed": "링크를 비활성화하지 못했습니다. 다시 시도해 주세요.", "fileViewer.publishFileFailed": "공유 링크를 만들지 못했습니다. 나중에 다시 시도해 주세요.", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3645,6 +3665,8 @@ export const ko: Dict = { "chat.input.viewAll": "전체 보기", "chat.artifact.export": "내보내기", "chat.artifact.pending": "생성 중…", + "chat.artifact.shareStatusUnknown": "상태 알 수 없음", + "chat.artifact.published": "공유됨", "chat.artifact.publish": "공유", "chat.edge.paused": "작업을 수동으로 일시 중지했습니다", "chat.edge.reconnectCta": "다시 연결", diff --git a/apps/web/src/i18n/locales/pl.ts b/apps/web/src/i18n/locales/pl.ts index 1be598f760..4cb9aaef57 100644 --- a/apps/web/src/i18n/locales/pl.ts +++ b/apps/web/src/i18n/locales/pl.ts @@ -239,6 +239,12 @@ export const pl: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "Link jest wyłączony.", 'fileViewer.commentSync.backfillFailedTitle': "Udostępnienie opublikowane", 'fileViewer.commentSync.backfillFailedBody': "Istniejące komentarze nie zostały jeszcze zsynchronizowane — odwiedzający chwilowo ich nie widzą.", + 'fileViewer.commentSync.backfillTerminalBody': "Nie udało się zsynchronizować niektórych istniejących komentarzy ze stroną udostępniania i nie będą ponawiane automatycznie. Link do udostępniania pozostaje dostępny.", + 'fileViewer.commentSync.backfillRetryingBody': "Istniejące komentarze nie zostały jeszcze zsynchronizowane — odwiedzający na razie ich nie widzą. Trwa automatyczna ponowna próba w tle.", + 'fileViewer.commentSync.backfillReopenedPendingBody': "Trwa synchronizacja komentarzy dla ponownie włączonego linku. Usunięte lub rozwiązane komentarze mogą nadal być widoczne na udostępnionej stronie.", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "Synchronizacja komentarzy nie została jeszcze zakończona. Usunięte lub rozwiązane komentarze mogą nadal być widoczne na udostępnionej stronie. Automatyczna ponowna próba trwa w tle.", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "Synchronizacja komentarzy nie została jeszcze zakończona. Usunięte lub rozwiązane komentarze mogą nadal być widoczne na udostępnionej stronie. Automatyczne ponawianie zostało zatrzymane; link nadal działa.", + 'fileViewer.commentSync.backfillPendingBody': "Trwa synchronizowanie istniejących komentarzy. Odwiedzający mogą ich jeszcze nie widzieć.", 'fileViewer.commentSync.alignFailedBody': "Synchronizacja komentarzy nie została zakończona — niektórych komentarzy może brakować.", 'fileViewer.shareGuide.title': "Eksport zakończony", 'fileViewer.shareGuide.description': "Udostępnij link i zaproś innych do przeglądania i zostawiania komentarzy.", @@ -2161,6 +2167,8 @@ export const pl: Dict = { 'chat.comments.sending': 'Wysyłanie…', 'chat.comments.edit': 'Edytuj', 'chat.comments.select': 'Zaznacz', + 'chat.comments.expandBody': 'Rozwiń', + 'chat.comments.collapseBody': 'Zwiń', 'chat.comments.selectAll': 'Zaznacz wszystko', 'chat.comments.deselect': 'Odznacz', 'chat.comments.nSelected': 'Zaznaczono: {n}', @@ -3071,7 +3079,7 @@ export const pl: Dict = { 'fileViewer.tweaks': 'Poprawki', 'fileViewer.tweaksUnavailable': 'Brak panelu poprawek w tym artefakcie', 'fileViewer.jsxModuleTitle': 'Brak samodzielnego podglądu', - 'fileViewer.jsxModuleBody': 'Ten plik to moduł komponentu ładowany przez inną stronę.', + 'fileViewer.jsxModuleBody': 'Można udostępniać tylko samodzielne pliki HTML. Najpierw wyeksportuj ten komponent jako HTML.', 'fileViewer.jsxModuleCta': 'Otwórz stronę, która go renderuje:', 'fileViewer.comment': 'Komentarz', 'fileViewer.edit': 'Edytuj', @@ -3283,7 +3291,19 @@ export const pl: Dict = { 'fileViewer.publishFileRequiresTeam': 'Opublikowanie linku publicznego wymaga zespołowej przestrzeni roboczej. Przełącz się na przestrzeń zespołu lub użyj Wdróż, aby opublikować ten plik.', 'fileViewer.publishFileRequiresWorkspace': 'Link do udostępniania wymaga zalogowanego obszaru roboczego. Zaloguj się do OpenDesign Cloud lub udostępnij ten plik przez wdrożenie.', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "Projekt przekracza limit udostępniania 20 MiB. Zmniejsz rozmiar HTML i powiązanych zasobów, a następnie spróbuj ponownie.", + "fileViewer.shareUpdateHint": "Plik zawiera nowe zmiany. Zaktualizuj link, aby udostępnić najnowszą wersję.", + "fileViewer.shareUpdateLink": "Zaktualizuj link", + "fileViewer.signInToUpdate": "Zaloguj się, aby zaktualizować", + "fileViewer.shareOutdatedSignInHint": "Ten plik ma nowe zmiany. Zaloguj się, aby zaktualizować lub wyłączyć link.", + "fileViewer.shareUpdateSuccess": "Link zaktualizowany", + "fileViewer.shareUpdateFailed": "Aktualizacja nie powiodła się. Poprzednia wersja nadal jest dostępna.", + "fileViewer.shareUpdateUncertain": "Wynik aktualizacji jest nieznany. Zawartość publicznego linku mogła się zmienić. Sprawdź link lub zatrzymaj udostępnianie przed ponowieniem.", + "fileViewer.shareUpdateRetry": "Ponów", "fileViewer.unpublishFileFailed": "Nie udało się wyłączyć linku. Spróbuj ponownie.", "fileViewer.publishFileFailed": "Nie udało się utworzyć linku. Spróbuj ponownie później.", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3645,6 +3665,8 @@ export const pl: Dict = { "chat.input.viewAll": "Pokaż wszystko", "chat.artifact.export": "Eksportuj", "chat.artifact.pending": "Generowanie…", + "chat.artifact.shareStatusUnknown": "Nieznany status", + "chat.artifact.published": "Udostępniono", "chat.artifact.publish": "Udostępnij", "chat.edge.paused": "Zadanie wstrzymane ręcznie", "chat.edge.reconnectCta": "Połącz ponownie", diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index b5d4132c01..c564aa3dc7 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -239,6 +239,12 @@ export const ptBR: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "O link está desativado.", 'fileViewer.commentSync.backfillFailedTitle': "Compartilhamento publicado", 'fileViewer.commentSync.backfillFailedBody': "Os comentários existentes ainda não foram sincronizados; os visitantes não os veem por enquanto.", + 'fileViewer.commentSync.backfillTerminalBody': "Alguns comentários existentes não puderam ser sincronizados com a página compartilhada e não serão repetidos automaticamente. O link de compartilhamento continua disponível.", + 'fileViewer.commentSync.backfillRetryingBody': "Os comentários existentes ainda não foram sincronizados; os visitantes não conseguem vê-los por enquanto. Tentaremos novamente em segundo plano.", + 'fileViewer.commentSync.backfillReopenedPendingBody': "Sincronizando os comentários do link de compartilhamento reativado. Comentários excluídos ou resolvidos podem continuar visíveis na página compartilhada.", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "A sincronização dos comentários ainda não terminou. Comentários excluídos ou resolvidos podem continuar visíveis na página compartilhada. Uma nova tentativa será feita automaticamente em segundo plano.", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "A sincronização dos comentários ainda não terminou. Comentários excluídos ou resolvidos podem continuar visíveis na página compartilhada. As tentativas automáticas pararam; o link continua disponível.", + 'fileViewer.commentSync.backfillPendingBody': "Sincronizando comentários existentes. Os visitantes talvez ainda não consigam vê-los.", 'fileViewer.commentSync.alignFailedBody': "O alinhamento dos comentários não foi concluído; alguns comentários podem estar faltando.", 'fileViewer.shareGuide.title': "Exportação concluída", 'fileViewer.shareGuide.description': "Compartilhe o link e convide outras pessoas para visualizar e deixar comentários.", @@ -2161,6 +2167,8 @@ export const ptBR: Dict = { 'chat.comments.sending': 'Enviando…', 'chat.comments.edit': 'Editar', 'chat.comments.select': 'Selecionar', + 'chat.comments.expandBody': 'Expandir', + 'chat.comments.collapseBody': 'Recolher', 'chat.comments.selectAll': 'Selecionar tudo', 'chat.comments.deselect': 'Desmarcar', 'chat.comments.nSelected': '{n} selecionados', @@ -3071,7 +3079,7 @@ export const ptBR: Dict = { 'fileViewer.tweaks': 'Ajustes', 'fileViewer.tweaksUnavailable': 'Sem painel de ajustes neste artefato', 'fileViewer.jsxModuleTitle': 'Sem visualização independente', - 'fileViewer.jsxModuleBody': 'Este arquivo é um módulo de componente carregado por outra página.', + 'fileViewer.jsxModuleBody': 'Somente arquivos HTML autocontidos podem ser compartilhados. Exporte este componente como HTML primeiro.', 'fileViewer.jsxModuleCta': 'Abra a página que o renderiza:', 'fileViewer.comment': 'Comentar', 'fileViewer.edit': 'Editar', @@ -3283,7 +3291,19 @@ export const ptBR: Dict = { 'fileViewer.publishFileRequiresTeam': 'Publicar um link público exige um espaço de trabalho de equipe. Mude para um espaço de equipe ou use Implantar para publicar este arquivo.', 'fileViewer.publishFileRequiresWorkspace': 'Um link de compartilhamento exige um workspace conectado. Entre no OpenDesign Cloud ou compartilhe este arquivo por deploy.', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "Este projeto excede o limite de compartilhamento de 20 MiB. Reduza o HTML e os recursos referenciados e tente novamente.", + "fileViewer.shareUpdateHint": "Este arquivo tem novas alterações. Atualize o link para compartilhar a versão mais recente.", + "fileViewer.shareUpdateLink": "Atualizar link", + "fileViewer.signInToUpdate": "Entre para atualizar", + "fileViewer.shareOutdatedSignInHint": "Este arquivo tem novas alterações. Entre para atualizar ou desativar o link.", + "fileViewer.shareUpdateSuccess": "Link atualizado", + "fileViewer.shareUpdateFailed": "Falha ao atualizar. A versão anterior continua disponível.", + "fileViewer.shareUpdateUncertain": "O resultado da atualização é desconhecido. O conteúdo do link público pode ter mudado. Verifique o link ou interrompa o compartilhamento antes de tentar novamente.", + "fileViewer.shareUpdateRetry": "Tentar novamente", "fileViewer.unpublishFileFailed": "Não foi possível desativar o link. Tente novamente.", "fileViewer.publishFileFailed": "Não foi possível criar o link de compartilhamento. Tente novamente mais tarde.", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3645,6 +3665,8 @@ export const ptBR: Dict = { "chat.input.viewAll": "Ver tudo", "chat.artifact.export": "Exportar", "chat.artifact.pending": "Gerando…", + "chat.artifact.shareStatusUnknown": "Status desconhecido", + "chat.artifact.published": "Compartilhado", "chat.artifact.publish": "Compartilhar", "chat.edge.paused": "Tarefa pausada manualmente", "chat.edge.reconnectCta": "Reconectar", diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index e86d12a97b..9af487c38b 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -239,6 +239,12 @@ export const ru: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "Ссылка отключена.", 'fileViewer.commentSync.backfillFailedTitle': "Ссылка опубликована", 'fileViewer.commentSync.backfillFailedBody': "Существующие комментарии ещё не синхронизированы — посетители пока их не видят.", + 'fileViewer.commentSync.backfillTerminalBody': "Некоторые существующие комментарии не удалось синхронизировать со страницей общего доступа; автоматических повторных попыток не будет. Ссылка остаётся доступной.", + 'fileViewer.commentSync.backfillRetryingBody': "Существующие комментарии ещё не синхронизированы — посетители пока их не видят. Повторная попытка выполняется автоматически в фоновом режиме.", + 'fileViewer.commentSync.backfillReopenedPendingBody': "Синхронизируются комментарии повторно включённой ссылки. Удалённые или закрытые комментарии могут по-прежнему отображаться на странице общего доступа.", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "Синхронизация комментариев ещё не завершена. Удалённые или закрытые комментарии могут по-прежнему отображаться на странице общего доступа. Повторная попытка выполняется автоматически в фоновом режиме.", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "Синхронизация комментариев ещё не завершена. Удалённые или закрытые комментарии могут по-прежнему отображаться на странице общего доступа. Автоматические попытки прекращены; ссылка остаётся доступной.", + 'fileViewer.commentSync.backfillPendingBody': "Синхронизируем существующие комментарии. Посетители пока могут их не видеть.", 'fileViewer.commentSync.alignFailedBody': "Согласование комментариев не завершено — некоторые комментарии могут отсутствовать.", 'fileViewer.shareGuide.title': "Экспорт завершён", 'fileViewer.shareGuide.description': "Поделитесь ссылкой и пригласите других посмотреть и оставить комментарии.", @@ -2161,6 +2167,8 @@ export const ru: Dict = { 'chat.comments.sending': 'Отправка…', 'chat.comments.edit': 'Редактировать', 'chat.comments.select': 'Выбрать', + 'chat.comments.expandBody': 'Развернуть', + 'chat.comments.collapseBody': 'Свернуть', 'chat.comments.selectAll': 'Выбрать всё', 'chat.comments.deselect': 'Отменить выбор', 'chat.comments.nSelected': 'Выбрано: {n}', @@ -3071,7 +3079,7 @@ export const ru: Dict = { 'fileViewer.tweaks': 'Настройки', 'fileViewer.tweaksUnavailable': 'В этом артефакте нет панели настроек', 'fileViewer.jsxModuleTitle': 'Нет отдельного предпросмотра', - 'fileViewer.jsxModuleBody': 'Этот файл является модулем компонента, который загружает другая страница.', + 'fileViewer.jsxModuleBody': 'Делиться можно только автономными HTML-файлами. Сначала экспортируйте этот компонент в HTML.', 'fileViewer.jsxModuleCta': 'Откройте страницу, которая его отображает:', 'fileViewer.comment': 'Комментарий', 'fileViewer.edit': 'Редактировать', @@ -3283,7 +3291,19 @@ export const ru: Dict = { 'fileViewer.publishFileRequiresTeam': 'Для публикации общедоступной ссылки нужно командное рабочее пространство. Переключитесь на командное пространство или опубликуйте файл через «Развернуть».', 'fileViewer.publishFileRequiresWorkspace': 'Для ссылки нужен вход в рабочее пространство. Войдите в OpenDesign Cloud или поделитесь файлом через деплой.', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "Проект превышает лимит публикации 20 MiB. Уменьшите размер HTML и связанных ресурсов и повторите попытку.", + "fileViewer.shareUpdateHint": "В файле есть новые изменения. Обновите ссылку, чтобы поделиться последней версией.", + "fileViewer.shareUpdateLink": "Обновить ссылку", + "fileViewer.signInToUpdate": "Войти и обновить", + "fileViewer.shareOutdatedSignInHint": "В файле есть изменения. Войдите, чтобы обновить или отключить ссылку.", + "fileViewer.shareUpdateSuccess": "Ссылка обновлена", + "fileViewer.shareUpdateFailed": "Не удалось обновить. Предыдущая версия всё ещё доступна.", + "fileViewer.shareUpdateUncertain": "Результат обновления неизвестен. Содержимое публичной ссылки могло измениться. Проверьте ссылку или остановите доступ перед повтором.", + "fileViewer.shareUpdateRetry": "Повторить", "fileViewer.unpublishFileFailed": "Не удалось отключить ссылку. Повторите попытку.", "fileViewer.publishFileFailed": "Не удалось создать ссылку. Повторите попытку позже.", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3645,6 +3665,8 @@ export const ru: Dict = { "chat.input.viewAll": "Показать всё", "chat.artifact.export": "Экспорт", "chat.artifact.pending": "Создание…", + "chat.artifact.shareStatusUnknown": "Статус неизвестен", + "chat.artifact.published": "Опубликовано", "chat.artifact.publish": "Поделиться", "chat.edge.paused": "Задача остановлена вручную", "chat.edge.reconnectCta": "Переподключиться", diff --git a/apps/web/src/i18n/locales/th.ts b/apps/web/src/i18n/locales/th.ts index 54e6dcda8e..ec0bbeda7f 100644 --- a/apps/web/src/i18n/locales/th.ts +++ b/apps/web/src/i18n/locales/th.ts @@ -239,6 +239,12 @@ export const th: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "ลิงก์ถูกปิดใช้งาน", 'fileViewer.commentSync.backfillFailedTitle': "เผยแพร่การแชร์แล้ว", 'fileViewer.commentSync.backfillFailedBody': "ความคิดเห็นที่มีอยู่ยังไม่ได้ซิงค์ ผู้เยี่ยมชมจะยังมองไม่เห็นชั่วคราว", + 'fileViewer.commentSync.backfillTerminalBody': "ไม่สามารถซิงค์ความคิดเห็นที่มีอยู่บางส่วนไปยังหน้าที่แชร์ และจะไม่มีการลองซ้ำโดยอัตโนมัติ ลิงก์แชร์ยังใช้งานได้", + 'fileViewer.commentSync.backfillRetryingBody': "ความคิดเห็นที่มีอยู่ยังไม่ได้ซิงค์ ผู้เยี่ยมชมยังมองไม่เห็น กำลังลองอีกครั้งโดยอัตโนมัติในเบื้องหลัง", + 'fileViewer.commentSync.backfillReopenedPendingBody': "กำลังซิงค์ความคิดเห็นของลิงก์แชร์ที่เปิดใช้อีกครั้ง ความคิดเห็นที่ลบหรือแก้ไขแล้วอาจยังปรากฏบนหน้าที่แชร์", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "การซิงค์ความคิดเห็นยังไม่เสร็จสิ้น ความคิดเห็นที่ลบหรือแก้ไขแล้วอาจยังปรากฏบนหน้าที่แชร์ ระบบกำลังลองอีกครั้งโดยอัตโนมัติในเบื้องหลัง", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "การซิงค์ความคิดเห็นยังไม่เสร็จสิ้น ความคิดเห็นที่ลบหรือแก้ไขแล้วอาจยังปรากฏบนหน้าที่แชร์ การลองใหม่อัตโนมัติหยุดแล้ว แต่ลิงก์แชร์ยังใช้งานได้", + 'fileViewer.commentSync.backfillPendingBody': "กำลังซิงค์ความคิดเห็นที่มีอยู่ ผู้เยี่ยมชมอาจยังมองไม่เห็น", 'fileViewer.commentSync.alignFailedBody': "การจัดเรียงความคิดเห็นยังไม่เสร็จสมบูรณ์ อาจมีความคิดเห็นบางส่วนไม่แสดง", 'fileViewer.shareGuide.title': "ส่งออกเสร็จสมบูรณ์", 'fileViewer.shareGuide.description': "แชร์ลิงก์และเชิญผู้อื่นมาดูและแสดงความคิดเห็น", @@ -2161,6 +2167,8 @@ export const th: Dict = { 'chat.comments.sending': 'กำลังส่ง…', 'chat.comments.edit': 'แก้ไข', 'chat.comments.select': 'เลือก', + 'chat.comments.expandBody': 'ขยาย', + 'chat.comments.collapseBody': 'ย่อ', 'chat.comments.selectAll': 'เลือกทั้งหมด', 'chat.comments.deselect': 'ยกเลิกการเลือก', 'chat.comments.nSelected': 'เลือกแล้ว {n} รายการ', @@ -3071,7 +3079,7 @@ export const th: Dict = { 'fileViewer.tweaks': 'ตั้งปรับแต่ง', 'fileViewer.tweaksUnavailable': 'ไม่มีพาเนลตั้งปรับแต่งในชิ้นงานนี้', 'fileViewer.jsxModuleTitle': 'ไม่มีการแสดงตัวอย่างแบบสแตนด์อโลน', - 'fileViewer.jsxModuleBody': 'ไฟล์นี้เป็นโมดูลคอมโพเนนต์ที่ถูกโหลดโดยหน้าอื่น', + 'fileViewer.jsxModuleBody': 'แชร์ได้เฉพาะไฟล์ HTML ที่ทำงานได้ด้วยตัวเองเท่านั้น โปรดส่งออกคอมโพเนนต์นี้เป็น HTML ก่อน', 'fileViewer.jsxModuleCta': 'เปิดหน้าที่แสดงผลไฟล์นี้:', 'fileViewer.comment': 'ช่วยคอมเมนต์', 'fileViewer.edit': 'จัดการแก้ไข', @@ -3283,7 +3291,19 @@ export const th: Dict = { 'fileViewer.publishFileRequiresTeam': 'การเผยแพร่ลิงก์สาธารณะต้องใช้พื้นที่ทำงานแบบทีม สลับไปยังพื้นที่ทำงานแบบทีม หรือใช้ Deploy เพื่อเผยแพร่ไฟล์นี้', 'fileViewer.publishFileRequiresWorkspace': 'การสร้างลิงก์แชร์ต้องเข้าสู่ระบบเวิร์กสเปซ เข้าสู่ระบบ OpenDesign Cloud หรือแชร์ไฟล์นี้ผ่านการดีพลอย', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "โปรเจกต์นี้เกินขีดจำกัดการแชร์ 20 MiB โปรดลดขนาด HTML และทรัพยากรที่อ้างอิง แล้วลองอีกครั้ง", + "fileViewer.shareUpdateHint": "ไฟล์นี้มีการเปลี่ยนแปลงใหม่ อัปเดตลิงก์เพื่อแชร์เวอร์ชันล่าสุด", + "fileViewer.shareUpdateLink": "อัปเดตลิงก์", + "fileViewer.signInToUpdate": "เข้าสู่ระบบเพื่ออัปเดต", + "fileViewer.shareOutdatedSignInHint": "ไฟล์นี้มีการเปลี่ยนแปลงใหม่ เข้าสู่ระบบเพื่ออัปเดตหรือปิดลิงก์", + "fileViewer.shareUpdateSuccess": "อัปเดตลิงก์แล้ว", + "fileViewer.shareUpdateFailed": "อัปเดตไม่สำเร็จ เวอร์ชันก่อนหน้ายังใช้งานได้", + "fileViewer.shareUpdateUncertain": "ไม่ทราบผลการอัปเดต เนื้อหาลิงก์สาธารณะอาจเปลี่ยนไป ตรวจสอบลิงก์หรือหยุดแชร์ก่อนลองอีกครั้ง", + "fileViewer.shareUpdateRetry": "ลองอีกครั้ง", "fileViewer.unpublishFileFailed": "ปิดใช้งานลิงก์ไม่สำเร็จ โปรดลองอีกครั้ง", "fileViewer.publishFileFailed": "สร้างลิงก์แชร์ไม่สำเร็จ โปรดลองอีกครั้งในภายหลัง", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3645,6 +3665,8 @@ export const th: Dict = { "chat.input.viewAll": "ดูทั้งหมด", "chat.artifact.export": "ส่งออก", "chat.artifact.pending": "กำลังสร้าง…", + "chat.artifact.shareStatusUnknown": "ไม่ทราบสถานะ", + "chat.artifact.published": "แชร์แล้ว", "chat.artifact.publish": "แชร์", "chat.edge.paused": "หยุดงานด้วยตนเองแล้ว", "chat.edge.reconnectCta": "เชื่อมต่อใหม่", diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index c33f22cdb7..f02c601d82 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -239,6 +239,12 @@ export const tr: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "Bağlantı devre dışı.", 'fileViewer.commentSync.backfillFailedTitle': "Paylaşım yayımlandı", 'fileViewer.commentSync.backfillFailedBody': "Mevcut yorumlar henüz eşitlenmedi; ziyaretçiler şimdilik bunları göremiyor.", + 'fileViewer.commentSync.backfillTerminalBody': "Bazı mevcut yorumlar paylaşım sayfasıyla eşitlenemedi ve otomatik olarak yeniden denenmeyecek. Paylaşım bağlantısı kullanılabilir durumda.", + 'fileViewer.commentSync.backfillRetryingBody': "Mevcut yorumlar henüz eşitlenmedi; ziyaretçiler şimdilik göremiyor. Arka planda otomatik olarak yeniden deneniyor.", + 'fileViewer.commentSync.backfillReopenedPendingBody': "Yeniden etkinleştirilen paylaşım bağlantısının yorumları eşitleniyor. Silinen veya çözümlenen yorumlar paylaşılan sayfada hâlâ görünebilir.", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "Yorum eşitlemesi henüz tamamlanmadı. Silinen veya çözümlenen yorumlar paylaşılan sayfada hâlâ görünebilir. Arka planda otomatik olarak yeniden deneniyor.", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "Yorum eşitlemesi henüz tamamlanmadı. Silinen veya çözümlenen yorumlar paylaşılan sayfada hâlâ görünebilir. Otomatik yeniden deneme durdu; paylaşım bağlantısı kullanılabilir.", + 'fileViewer.commentSync.backfillPendingBody': "Mevcut yorumlar eşitleniyor. Ziyaretçiler henüz göremeyebilir.", 'fileViewer.commentSync.alignFailedBody': "Yorum eşitlemesi tamamlanmadı; bazı yorumlar görünmeyebilir.", 'fileViewer.shareGuide.title': "Dışa aktarma tamamlandı", 'fileViewer.shareGuide.description': "Bağlantıyı paylaşın ve başkalarını görüntülemeye ve yorum bırakmaya davet edin.", @@ -2161,6 +2167,8 @@ export const tr: Dict = { 'chat.comments.sending': 'Gönderiliyor…', 'chat.comments.edit': 'Düzenle', 'chat.comments.select': 'Seç', + 'chat.comments.expandBody': 'Genişlet', + 'chat.comments.collapseBody': 'Daralt', 'chat.comments.selectAll': 'Tümünü seç', 'chat.comments.deselect': 'Seçimi kaldır', 'chat.comments.nSelected': '{n} seçildi', @@ -3071,7 +3079,7 @@ export const tr: Dict = { 'fileViewer.tweaks': 'Düzenlemeler', 'fileViewer.tweaksUnavailable': 'Bu artefaktta düzenleme paneli yok', 'fileViewer.jsxModuleTitle': 'Bağımsız önizleme yok', - 'fileViewer.jsxModuleBody': 'Bu dosya başka bir sayfa tarafından yüklenen bir bileşen modülüdür.', + 'fileViewer.jsxModuleBody': 'Yalnızca bağımsız HTML dosyaları paylaşılabilir. Önce bu bileşeni HTML olarak dışa aktarın.', 'fileViewer.jsxModuleCta': 'Onu işleyen sayfayı açın:', 'fileViewer.comment': 'Yorum', 'fileViewer.edit': 'Düzenle', @@ -3283,7 +3291,19 @@ export const tr: Dict = { 'fileViewer.publishFileRequiresTeam': 'Herkese açık bağlantı yayınlamak için ekip çalışma alanı gerekir. Bir ekip çalışma alanına geçin veya bu dosyayı Dağıt ile yayınlayın.', 'fileViewer.publishFileRequiresWorkspace': 'Paylaşım bağlantısı için oturum açılmış bir çalışma alanı gerekir. OpenDesign Cloud\'da oturum açın veya bu dosyayı bir dağıtımla paylaşın.', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "Bu proje 20 MiB paylaşım sınırını aşıyor. HTML ve başvurulan kaynakların boyutunu küçültüp yeniden deneyin.", + "fileViewer.shareUpdateHint": "Bu dosyada yeni değişiklikler var. En son sürümü paylaşmak için bağlantıyı güncelleyin.", + "fileViewer.shareUpdateLink": "Bağlantıyı güncelle", + "fileViewer.signInToUpdate": "Giriş yapıp güncelle", + "fileViewer.shareOutdatedSignInHint": "Bu dosyada yeni değişiklikler var. Bağlantıyı güncellemek veya kapatmak için giriş yapın.", + "fileViewer.shareUpdateSuccess": "Bağlantı güncellendi", + "fileViewer.shareUpdateFailed": "Güncelleme başarısız. Önceki sürüm hâlâ kullanılabilir.", + "fileViewer.shareUpdateUncertain": "Güncelleme sonucu bilinmiyor. Herkese açık bağlantının içeriği değişmiş olabilir. Yeniden denemeden önce bağlantıyı kontrol edin veya paylaşımı durdurun.", + "fileViewer.shareUpdateRetry": "Yeniden dene", "fileViewer.unpublishFileFailed": "Bağlantı devre dışı bırakılamadı. Lütfen tekrar deneyin.", "fileViewer.publishFileFailed": "Paylaşım bağlantısı oluşturulamadı. Lütfen daha sonra tekrar deneyin.", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3645,6 +3665,8 @@ export const tr: Dict = { "chat.input.viewAll": "Tümünü göster", "chat.artifact.export": "Dışa aktar", "chat.artifact.pending": "Oluşturuluyor…", + "chat.artifact.shareStatusUnknown": "Durum bilinmiyor", + "chat.artifact.published": "Paylaşıldı", "chat.artifact.publish": "Paylaş", "chat.edge.paused": "Görev el ile duraklatıldı", "chat.edge.reconnectCta": "Yeniden bağlan", diff --git a/apps/web/src/i18n/locales/uk.ts b/apps/web/src/i18n/locales/uk.ts index e3b3406710..f5334cf9e1 100644 --- a/apps/web/src/i18n/locales/uk.ts +++ b/apps/web/src/i18n/locales/uk.ts @@ -239,6 +239,12 @@ export const uk: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "Посилання вимкнено.", 'fileViewer.commentSync.backfillFailedTitle': "Посилання опубліковано", 'fileViewer.commentSync.backfillFailedBody': "Наявні коментарі ще не синхронізовано — відвідувачі поки що їх не бачать.", + 'fileViewer.commentSync.backfillTerminalBody': "Не вдалося синхронізувати деякі наявні коментарі зі сторінкою поширення; автоматичних повторних спроб не буде. Посилання залишається доступним.", + 'fileViewer.commentSync.backfillRetryingBody': "Наявні коментарі ще не синхронізовано — відвідувачі поки що їх не бачать. У фоновому режимі триває автоматична повторна спроба.", + 'fileViewer.commentSync.backfillReopenedPendingBody': "Триває синхронізація коментарів повторно ввімкненого посилання. Видалені або вирішені коментарі можуть досі відображатися на сторінці спільного доступу.", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "Синхронізація коментарів ще не завершена. Видалені або вирішені коментарі можуть досі відображатися на сторінці спільного доступу. Повторна спроба виконується автоматично у фоновому режимі.", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "Синхронізація коментарів ще не завершена. Видалені або вирішені коментарі можуть досі відображатися на сторінці спільного доступу. Автоматичні спроби припинено; посилання залишається доступним.", + 'fileViewer.commentSync.backfillPendingBody': "Триває синхронізація наявних коментарів. Відвідувачі поки що можуть їх не бачити.", 'fileViewer.commentSync.alignFailedBody': "Узгодження коментарів не завершено — деякі коментарі можуть бути відсутні.", 'fileViewer.shareGuide.title': "Експорт завершено", 'fileViewer.shareGuide.description': "Поділіться посиланням і запросіть інших переглянути та залишити коментарі.", @@ -2161,6 +2167,8 @@ export const uk: Dict = { 'chat.comments.sending': 'Надсилання…', 'chat.comments.edit': 'Редагувати', 'chat.comments.select': 'Вибрати', + 'chat.comments.expandBody': 'Розгорнути', + 'chat.comments.collapseBody': 'Згорнути', 'chat.comments.selectAll': 'Вибрати все', 'chat.comments.deselect': 'Скасувати вибір', 'chat.comments.nSelected': 'Вибрано: {n}', @@ -3071,7 +3079,7 @@ export const uk: Dict = { 'fileViewer.tweaks': 'Настройки', 'fileViewer.tweaksUnavailable': 'Немає панелі налаштувань у цьому артефакті', 'fileViewer.jsxModuleTitle': 'Немає окремого попереднього перегляду', - 'fileViewer.jsxModuleBody': 'Цей файл є модулем компонента, який завантажує інша сторінка.', + 'fileViewer.jsxModuleBody': 'Ділитися можна лише автономними HTML-файлами. Спочатку експортуйте цей компонент у HTML.', 'fileViewer.jsxModuleCta': 'Відкрийте сторінку, що його відображає:', 'fileViewer.comment': 'Коментар', 'fileViewer.edit': 'Редагувати', @@ -3283,7 +3291,19 @@ export const uk: Dict = { 'fileViewer.publishFileRequiresTeam': 'Щоб опублікувати загальнодоступне посилання, потрібен командний робочий простір. Перейдіть до командного простору або скористайтеся «Розгорнути».', 'fileViewer.publishFileRequiresWorkspace': 'Для посилання потрібен вхід у робочий простір. Увійдіть в OpenDesign Cloud або поділіться файлом через деплой.', + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', 'fileViewer.publishFileTooLarge': "Проєкт перевищує ліміт поширення 20 MiB. Зменште розмір HTML і пов’язаних ресурсів та спробуйте ще раз.", + "fileViewer.shareUpdateHint": "У файлі є нові зміни. Оновіть посилання, щоб поділитися останньою версією.", + "fileViewer.shareUpdateLink": "Оновити посилання", + "fileViewer.signInToUpdate": "Увійти для оновлення", + "fileViewer.shareOutdatedSignInHint": "У файлі є нові зміни. Увійдіть, щоб оновити або вимкнути посилання.", + "fileViewer.shareUpdateSuccess": "Посилання оновлено", + "fileViewer.shareUpdateFailed": "Не вдалося оновити. Попередня версія все ще доступна.", + "fileViewer.shareUpdateUncertain": "Результат оновлення невідомий. Вміст публічного посилання міг змінитися. Перевірте посилання або зупиніть доступ перед повторною спробою.", + "fileViewer.shareUpdateRetry": "Повторити", "fileViewer.unpublishFileFailed": "Не вдалося вимкнути посилання. Повторіть спробу.", "fileViewer.publishFileFailed": "Не вдалося створити посилання. Повторіть спробу пізніше.", 'fileViewer.workspaceShareSuccess': 'Shared with workspace members', @@ -3645,6 +3665,8 @@ export const uk: Dict = { "chat.input.viewAll": "Показати все", "chat.artifact.export": "Експортувати", "chat.artifact.pending": "Створення…", + "chat.artifact.shareStatusUnknown": "Невідомий стан", + "chat.artifact.published": "Поширено", "chat.artifact.publish": "Поділитися", "chat.edge.paused": "Завдання зупинено вручну", "chat.edge.reconnectCta": "Підключитися знову", diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 0072ecd532..b869ae48f9 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -247,6 +247,12 @@ export const zhCN: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "链接已停用。", 'fileViewer.commentSync.backfillFailedTitle': "分享已发布", 'fileViewer.commentSync.backfillFailedBody': "已有评论暂未同步,访客暂时看不到", + 'fileViewer.commentSync.backfillTerminalBody': "部分已有评论未能同步到分享页,系统不会自动重试。分享链接仍可使用。", + 'fileViewer.commentSync.backfillRetryingBody': "已有评论暂未同步,访客暂时看不到。正在后台自动重试。", + 'fileViewer.commentSync.backfillReopenedPendingBody': "正在同步重新开启的分享链接;分享页可能仍显示已删除或已处理的评论。", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "评论同步尚未完成,分享页可能仍显示已删除或已处理的评论。正在后台自动重试。", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "评论同步尚未完成,分享页可能仍显示已删除或已处理的评论。自动重试已停止;分享链接仍可使用。", + 'fileViewer.commentSync.backfillPendingBody': "正在同步已有评论,访客暂时可能看不到。", 'fileViewer.commentSync.alignFailedBody': "评论对齐未完成,可能有评论没显示出来", 'fileViewer.shareGuide.title': "导出完成", 'fileViewer.shareGuide.description': "分享链接,邀请大家查看并留下评论。", @@ -2251,6 +2257,8 @@ export const zhCN: Dict = { "chat.comments.sending": "发送中…", "chat.comments.edit": "编辑", "chat.comments.select": "选择", + "chat.comments.expandBody": "展开", + "chat.comments.collapseBody": "收起", "chat.comments.selectAll": "全选", "chat.comments.deselect": "取消选择", "chat.comments.nSelected": "已选择 {n} 个", @@ -3305,7 +3313,7 @@ export const zhCN: Dict = { "fileViewer.tweaks": "调整", "fileViewer.tweaksUnavailable": "此作品中没有调整面板", "fileViewer.jsxModuleTitle": "无独立预览", - "fileViewer.jsxModuleBody": "此文件是由其他页面加载的组件模块。", + "fileViewer.jsxModuleBody": "只能分享独立的 HTML 文件。请先将此组件导出为 HTML。", "fileViewer.jsxModuleCta": "请打开渲染它的页面:", "fileViewer.comment": "注释", "fileViewer.edit": "编辑", @@ -3455,9 +3463,21 @@ export const zhCN: Dict = { "fileViewer.publishFileRequiresTeam": "发布公开链接需要团队工作区。请切换到团队工作区,或使用「部署」发布此文件。", "fileViewer.publishFileRequiresWorkspace": "生成分享链接需要登录工作区。请登录 OpenDesign Cloud,或改用「部署」分享此文件。", - "fileViewer.publishFileTooLarge": "项目超过 20 MiB 分享上限。请减小 HTML 和引用资源的总大小后重试。", + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', + "fileViewer.publishFileTooLarge": "项目超过 20 MiB 分享上限。请减小 HTML 和引用资源的总大小后重试。", + "fileViewer.shareUpdateHint": "文件有新改动。更新链接以分享最新版本。", + "fileViewer.shareUpdateLink": "更新链接", + "fileViewer.signInToUpdate": "登录后更新", + "fileViewer.shareOutdatedSignInHint": "文件有新改动,登录后可更新或关闭链接。", + "fileViewer.shareUpdateSuccess": "链接已更新", + "fileViewer.shareUpdateFailed": "更新失败,上一版仍可访问。", + "fileViewer.shareUpdateUncertain": "更新结果尚不确定,公开链接的内容可能已变化。请先检查链接或停止分享,再重试。", + "fileViewer.shareUpdateRetry": "重试", "fileViewer.unpublishFileFailed": "关闭链接失败,请重试。", - "fileViewer.publishFileFailed": "生成分享链接失败,请稍后重试。", + "fileViewer.publishFileFailed": "上传失败,请检查网络后重试。", "fileViewer.workspaceShareSuccess": "已分享给工作空间成员", "fileViewer.workspaceShareFailed": "分享到工作空间失败", "fileViewer.workspaceUnshareSuccess": "已移回仅自己可见", @@ -3830,6 +3850,8 @@ export const zhCN: Dict = { "chat.input.viewAll": "查看全部", "chat.artifact.export": "导出", "chat.artifact.pending": "生成中…", + "chat.artifact.shareStatusUnknown": "分享状态未知", + "chat.artifact.published": "已分享", "chat.artifact.publish": "分享", "chat.edge.paused": "已手动暂停任务", "chat.edge.reconnectCta": "重新连接", diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 5bc546033a..68bcaf63b2 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -247,6 +247,12 @@ export const zhTW: Dict = { 'fileViewer.commentSync.shareStoppedTeam': "連結已停用。", 'fileViewer.commentSync.backfillFailedTitle': "分享已發布", 'fileViewer.commentSync.backfillFailedBody': "已有評論暫未同步,訪客暫時看不到", + 'fileViewer.commentSync.backfillTerminalBody': "部分既有評論未能同步至分享頁,系統不會自動重試。分享連結仍可使用。", + 'fileViewer.commentSync.backfillRetryingBody': "既有評論尚未同步,訪客暫時看不到。正在背景自動重試。", + 'fileViewer.commentSync.backfillReopenedPendingBody': "正在同步重新開啟的分享連結;分享頁可能仍顯示已刪除或已處理的留言。", + 'fileViewer.commentSync.backfillReopenedRetryingBody': "留言同步尚未完成,分享頁可能仍顯示已刪除或已處理的留言。正在背景自動重試。", + 'fileViewer.commentSync.backfillReopenedTerminalBody': "留言同步尚未完成,分享頁可能仍顯示已刪除或已處理的留言。自動重試已停止;分享連結仍可使用。", + 'fileViewer.commentSync.backfillPendingBody': "正在同步既有評論,訪客暫時可能看不到。", 'fileViewer.commentSync.alignFailedBody': "評論對齊未完成,可能有評論沒顯示出來", 'fileViewer.shareGuide.title': "匯出完成", 'fileViewer.shareGuide.description': "分享連結,邀請大家查看並留下留言。", @@ -2259,6 +2265,8 @@ export const zhTW: Dict = { "chat.comments.sending": "傳送中…", "chat.comments.edit": "編輯", "chat.comments.select": "選取", + "chat.comments.expandBody": "展開", + "chat.comments.collapseBody": "收起", "chat.comments.selectAll": "全選", "chat.comments.deselect": "取消選取", "chat.comments.nSelected": "已選取 {n} 項", @@ -3313,7 +3321,7 @@ export const zhTW: Dict = { "fileViewer.tweaks": "調整", "fileViewer.tweaksUnavailable": "此作品中沒有調整面板", "fileViewer.jsxModuleTitle": "無獨立預覽", - "fileViewer.jsxModuleBody": "此檔案是由其他頁面載入的元件模組。", + "fileViewer.jsxModuleBody": "只能分享獨立的 HTML 檔案。請先將此元件匯出為 HTML。", "fileViewer.jsxModuleCta": "請開啟轉譯它的頁面:", "fileViewer.comment": "註釋", "fileViewer.edit": "編輯", @@ -3464,7 +3472,19 @@ export const zhTW: Dict = { "fileViewer.publishFileRequiresTeam": "發布公開連結需要團隊工作區。請切換到團隊工作區,或使用「部署」發布此檔案。", "fileViewer.publishFileRequiresWorkspace": "產生分享連結需要登入工作區。請登入 OpenDesign Cloud,或改用「部署」分享此檔案。", - "fileViewer.publishFileTooLarge": "專案超過 20 MiB 分享上限。請減小 HTML 和引用資源的總大小後重試。", + 'fileViewer.shareMissingRefs': 'Some referenced files could not be included in the share. The link can still be published.', + 'fileViewer.shareMissingRefsToggle': 'Show affected files', + 'fileViewer.sharePlanUnavailable': 'The share contents could not be checked. You can still publish, but the missing-file check is unavailable.', + 'fileViewer.shareInvalidRefs': 'Invalid or unreadable file', + "fileViewer.publishFileTooLarge": "專案超過 20 MiB 分享上限。請減小 HTML 和引用資源的總大小後重試。", + "fileViewer.shareUpdateHint": "檔案有新變更。更新連結以分享最新版本。", + "fileViewer.shareUpdateLink": "更新連結", + "fileViewer.signInToUpdate": "登入後更新", + "fileViewer.shareOutdatedSignInHint": "檔案有新變更,登入後可更新或關閉連結。", + "fileViewer.shareUpdateSuccess": "連結已更新", + "fileViewer.shareUpdateFailed": "更新失敗,上一版仍可存取。", + "fileViewer.shareUpdateUncertain": "更新結果尚未確定,公開連結的內容可能已變更。請先檢查連結或停止分享,再重試。", + "fileViewer.shareUpdateRetry": "重試", "fileViewer.unpublishFileFailed": "關閉連結失敗,請重試。", "fileViewer.publishFileFailed": "產生分享連結失敗,請稍後重試。", "fileViewer.workspaceShareSuccess": "已分享給工作空間成員", @@ -3840,6 +3860,8 @@ export const zhTW: Dict = { "chat.input.viewAll": "查看全部", "chat.artifact.export": "匯出", "chat.artifact.pending": "產生中…", + "chat.artifact.shareStatusUnknown": "分享狀態未知", + "chat.artifact.published": "已分享", "chat.artifact.publish": "分享", "chat.edge.paused": "已手動暫停任務", "chat.edge.reconnectCta": "重新連線", diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index 0e93f6ff2a..44f2e4f03a 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -139,6 +139,12 @@ export interface Dict { 'fileViewer.commentSync.shareStoppedTeam': string; 'fileViewer.commentSync.backfillFailedTitle': string; 'fileViewer.commentSync.backfillFailedBody': string; + 'fileViewer.commentSync.backfillRetryingBody': string; + 'fileViewer.commentSync.backfillReopenedPendingBody': string; + 'fileViewer.commentSync.backfillReopenedRetryingBody': string; + 'fileViewer.commentSync.backfillReopenedTerminalBody': string; + 'fileViewer.commentSync.backfillTerminalBody': string; + 'fileViewer.commentSync.backfillPendingBody': string; 'fileViewer.commentSync.alignFailedBody': string; 'fileViewer.shareGuide.title': string; 'fileViewer.shareGuide.description': string; @@ -3021,6 +3027,8 @@ export interface Dict { 'chat.comments.sending': string; 'chat.comments.edit': string; 'chat.comments.select': string; + 'chat.comments.expandBody': string; + 'chat.comments.collapseBody': string; 'chat.comments.selectAll': string; 'chat.comments.deselect': string; 'chat.comments.nSelected': string; @@ -4214,7 +4222,19 @@ export interface Dict { 'fileViewer.publishFileRequiresTeam': string; 'fileViewer.publishFileRequiresWorkspace': string; 'fileViewer.publishFileFailed': string; + 'fileViewer.shareUpdateHint': string; + 'fileViewer.shareUpdateLink': string; + 'fileViewer.signInToUpdate': string; + 'fileViewer.shareOutdatedSignInHint': string; + 'fileViewer.shareUpdateSuccess': string; + 'fileViewer.shareUpdateFailed': string; + 'fileViewer.shareUpdateUncertain': string; + 'fileViewer.shareUpdateRetry': string; 'fileViewer.unpublishFileFailed': string; + 'fileViewer.shareMissingRefs': string; + 'fileViewer.shareMissingRefsToggle': string; + 'fileViewer.shareInvalidRefs': string; + 'fileViewer.sharePlanUnavailable': string; 'fileViewer.publishFileTooLarge': string; 'fileViewer.workspaceShareSuccess': string; 'fileViewer.workspaceShareFailed': string; @@ -4609,6 +4629,8 @@ export interface Dict { 'chat.artifact.export': string; 'chat.artifact.pending': string; 'chat.artifact.publish': string; + 'chat.artifact.published': string; + 'chat.artifact.shareStatusUnknown': string; 'chat.edge.paused': string; 'chat.edge.reconnectCta': string; 'chat.edge.reconnectDetail': string; diff --git a/apps/web/src/providers/registry.ts b/apps/web/src/providers/registry.ts index c9995a78e4..fce3ccfa5e 100644 --- a/apps/web/src/providers/registry.ts +++ b/apps/web/src/providers/registry.ts @@ -3,7 +3,9 @@ import { workspaceContextHasTeamIdentity, type PublicFileManualRevokeRequiredData, type PublicProjectFilePublication, + type ProjectFilePublicShareResponse, type ShareUnpublishResponse, + type SharePublishRequest, } from '@open-design/contracts'; import { boundedRequestErrorCode } from '../analytics/workspace'; import type { @@ -1527,34 +1529,53 @@ async function prepareConnectorAuthConfig(connectorId: string): Promise<{ status } function openConnectorAuthRedirect(authWindow: Window | null, redirectUrl: string): void { + const parsed = new URL(redirectUrl); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw new Error('Invalid connector authorization URL'); + const safeUrl = parsed.href; if (authWindow) { - renderConnectorAuthRedirect(authWindow, redirectUrl); + renderConnectorAuthRedirect(authWindow, safeUrl); try { - authWindow.location.replace(redirectUrl); + authWindow.location.replace(safeUrl); return; } catch { // Some embedded browsers block async popup navigation. Leave the // clickable fallback in the popup so the user can continue. } } - const opened = window.open(redirectUrl, '_blank'); - if (!opened) window.location.assign(redirectUrl); + const opened = window.open(safeUrl, '_blank'); + if (!opened) window.location.assign(safeUrl); +} + +function connectorAuthPage(authWindow: Window, title: string, body: string, maxWidth: number): HTMLElement { + const doc = authWindow.document; + doc.title = title; + const main = doc.createElement('main'); + main.style.cssText = "min-height:100vh;display:grid;place-items:center;margin:0;background:#0f1115;color:#f6f7fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"; + const content = doc.createElement('div'); + content.style.cssText = 'display:grid;gap:14px;justify-items:center;text-align:center;padding:32px;'; + const heading = doc.createElement('div'); + heading.style.cssText = 'font-size:15px;font-weight:600;'; + heading.textContent = title; + const description = doc.createElement('div'); + description.style.cssText = `max-width:${maxWidth}px;color:rgba(246,247,251,.72);font-size:13px;line-height:1.5;`; + description.textContent = body; + content.append(heading, description); + main.append(content); + doc.body.replaceChildren(main); + return content; } function renderConnectorAuthLoading(authWindow: Window | null, copy: { title: string; body: string }): void { if (!authWindow) return; try { - authWindow.document.title = 'Connecting…'; - authWindow.document.body.innerHTML = ` -
-
- -
${escapeHtmlText(copy.title)}
-
${escapeHtmlText(copy.body)}
-
- -
- `; + const content = connectorAuthPage(authWindow, copy.title, copy.body, 300); + const spinner = authWindow.document.createElement('div'); + spinner.setAttribute('aria-hidden', 'true'); + spinner.style.cssText = 'width:28px;height:28px;border-radius:999px;border:3px solid rgba(255,255,255,.22);border-top-color:#fff;animation:od-spin .8s linear infinite;'; + content.prepend(spinner); + const style = authWindow.document.createElement('style'); + style.textContent = '@keyframes od-spin{to{transform:rotate(360deg)}}'; + content.append(style); } catch { /* Popup may be unavailable or already navigated; ignore. */ } @@ -1562,96 +1583,28 @@ function renderConnectorAuthLoading(authWindow: Window | null, copy: { title: st function renderConnectorAuthInfo(authWindow: Window | null, copy: { title: string; body: string }): void { if (!authWindow) return; - try { - authWindow.document.title = copy.title; - authWindow.document.body.innerHTML = ` -
-
-
${escapeHtmlText(copy.title)}
-
${escapeHtmlText(copy.body)}
-
-
- `; - } catch { - /* Popup may be unavailable or already navigated; ignore. */ - } + try { connectorAuthPage(authWindow, copy.title, copy.body, 360); } + catch { /* Popup may be unavailable or already navigated; ignore. */ } } function renderConnectorAuthRedirect(authWindow: Window, redirectUrl: string): void { try { - authWindow.document.title = 'Continue authorization'; - authWindow.document.body.innerHTML = ` -
-
-
Continue authorization
-
If this window does not redirect automatically, use the button below.
- Open Composio -
-
- `; + const content = connectorAuthPage(authWindow, 'Continue authorization', + 'If this window does not redirect automatically, use the button below.', 300); + const link = authWindow.document.createElement('a'); + link.href = redirectUrl; + link.textContent = 'Open Composio'; + link.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;min-width:164px;border-radius:8px;padding:9px 14px;background:#df7b56;color:#fff;text-decoration:none;font-size:13px;font-weight:600;'; + content.append(link); } catch { /* Popup may already be cross-origin; navigation fallback still runs. */ } } -async function readConnectorApiErrorMessage(resp: Response): Promise { - try { - const payload = await resp.json() as { error?: { message?: string }; message?: string }; - return payload.error?.message ?? payload.message ?? `Connection failed (${resp.status})`; - } catch { - return `Connection failed (${resp.status})`; - } -} - function renderConnectorAuthError(authWindow: Window | null, message: string): void { if (!authWindow) return; - try { - authWindow.document.title = 'Connection failed'; - authWindow.document.body.innerHTML = ` -
-
-
Connection failed
-
${escapeHtmlText(message)}
-
-
- `; - } catch { - /* Popup may be unavailable or already navigated; ignore. */ - } -} - -function escapeHtmlText(value: string): string { - return value.replace(/[&<>]/g, (char) => { - switch (char) { - case '&': - return '&'; - case '<': - return '<'; - case '>': - return '>'; - default: - return char; - } - }); -} - -function escapeHtmlAttribute(value: string): string { - return value.replace(/[&<>"']/g, (char) => { - switch (char) { - case '&': - return '&'; - case '<': - return '<'; - case '>': - return '>'; - case '"': - return '"'; - case "'": - return '''; - default: - return char; - } - }); + try { connectorAuthPage(authWindow, 'Connection failed', message, 360); } + catch { /* Popup may be unavailable or already navigated; ignore. */ } } export async function disconnectConnector(connectorId: string): Promise { @@ -1949,6 +1902,7 @@ export async function publishProjectFilePublic( fileName: string, workspaceContext?: WorkspaceCollabContext | null, requestId?: string, + mode?: SharePublishRequest['mode'], ): Promise { // Carry the active workspace identity so the daemon's `canShareProjectsForRequest` // gate (apps/daemon/src/routes/collab-sync.ts) reads the real permission bit @@ -1958,14 +1912,16 @@ export async function publishProjectFilePublic( `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(fileName)}/publish-public`, { method: 'POST', - ...(workspaceContext || requestId + ...(workspaceContext || requestId || mode ? { headers: { ...(workspaceContext ? workspaceProjectHeaders(workspaceContext) : {}), ...clientRequestIdHeaders(requestId), + ...(mode ? { 'content-type': 'application/json' } : {}), }, } : {}), + ...(mode ? { body: JSON.stringify({ mode } satisfies SharePublishRequest) } : {}), }, ); if (!resp.ok) { @@ -2010,11 +1966,17 @@ export async function publishProjectFilePublic( return (await resp.json()) as WebPublicProjectFileResponse; } -export async function fetchProjectFilePublicPublication( +export async function fetchProjectFileSharePlan(projectId: string, fileName: string, workspaceContext?: WorkspaceCollabContext | null) { + const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(fileName)}/share-plan`, { method: 'POST', headers: workspaceContext ? workspaceProjectHeaders(workspaceContext) : undefined }); + if (!resp.ok) throw new Error(`Share preflight failed (${resp.status})`); + return resp.json() as Promise; +} + +export async function fetchProjectFilePublicShareState( projectId: string, fileName: string, workspaceContext?: WorkspaceCollabContext | null, -): Promise { +): Promise { const resp = await fetch( `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(fileName)}/publish-public`, workspaceContext ? { headers: workspaceProjectHeaders(workspaceContext) } : undefined, @@ -2031,8 +1993,17 @@ export async function fetchProjectFilePublicPublication( : payload?.message; throw new Error(errorMessage || `Fetch publish state failed (${resp.status})`); } - const payload = (await resp.json()) as { publication?: WebPublicProjectFileResponse | null }; - return payload.publication ?? null; + return (await resp.json()) as ProjectFilePublicShareResponse; +} + +/** Compatibility read for surfaces that only need the link, not its freshness. */ +export async function fetchProjectFilePublicPublication( + projectId: string, + fileName: string, + workspaceContext?: WorkspaceCollabContext | null, +): Promise { + const state = await fetchProjectFilePublicShareState(projectId, fileName, workspaceContext); + return state.publication; } export async function unpublishProjectFilePublic( diff --git a/apps/web/src/styles/viewer/core.css b/apps/web/src/styles/viewer/core.css index e95cc468f0..209c1c82e5 100644 --- a/apps/web/src/styles/viewer/core.css +++ b/apps/web/src/styles/viewer/core.css @@ -2396,6 +2396,21 @@ word-break: break-word; white-space: pre-wrap; } +.comment-side-body.is-clamped { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; +} +.comment-side-expand { + align-self: flex-start; + padding: 2px 0 0; + border: 0; + background: transparent; + color: var(--comment-accent); + font: inherit; + cursor: pointer; +} .comment-side-attachments { display: flex; flex-wrap: wrap; diff --git a/apps/web/src/styles/viewer/routines.css b/apps/web/src/styles/viewer/routines.css index 8323ea9e73..a6e2e0f4a8 100644 --- a/apps/web/src/styles/viewer/routines.css +++ b/apps/web/src/styles/viewer/routines.css @@ -130,6 +130,63 @@ z-index: 1000; pointer-events: auto; } +/* Scoped dark pill for share updates and deletion residual feedback. */ +.od-toast.od-toast-share-feedback { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + width: max-content; + max-width: min(480px, calc(100vw - 32px)); + padding: 6px 42px 6px 14px; + border-radius: 999px; + background: #202020; + color: #fff; + font-size: 12px; + line-height: 16px; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} +.od-toast.od-toast-share-feedback .od-toast-body { + min-width: 0; +} +.od-toast.od-toast-share-feedback .od-toast-details { + flex: 0 0 100%; + order: 3; +} +.od-toast.od-toast-share-feedback .od-toast-message { + font-size: 12px; + font-weight: 400; +} +.od-toast.od-toast-share-feedback .od-toast-icon { + color: inherit; +} +.od-toast.od-toast-share-feedback .od-toast-action { + flex: 0 0 auto; + padding: 4px 8px; + border: 1px solid rgba(255, 255, 255, 0.28); + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + color: #fff; + font-size: 12px; + line-height: 16px; + cursor: pointer; +} +.od-toast.od-toast-share-feedback .od-toast-action:hover { + background: rgba(255, 255, 255, 0.22); +} +.od-toast.od-toast-share-feedback .od-toast-close { + color: #fff; +} +.od-toast.od-toast-share-feedback.tone-success, +.od-toast.od-toast-share-feedback.tone-error { + border: 0; + background: #202020; + color: #fff; +} +.od-toast.od-toast-share-feedback.tone-success .od-toast-icon { + color: inherit; +} .workspace.has-browser-snapshot-toast { position: relative; } diff --git a/apps/web/tests/components/App.project-account-cluster.test.tsx b/apps/web/tests/components/App.project-account-cluster.test.tsx index d6bae9ae5d..5bbd3eb2be 100644 --- a/apps/web/tests/components/App.project-account-cluster.test.tsx +++ b/apps/web/tests/components/App.project-account-cluster.test.tsx @@ -14,6 +14,8 @@ import { useEffect } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { App } from '../../src/App'; +import { AMR_LOGIN_STATUS_EVENT } from '../../src/components/amrLoginPolling'; +import { fetchVelaLoginStatus } from '../../src/providers/daemon'; import type { Route } from '../../src/router'; import type { AppConfig, Project } from '../../src/types'; import type { @@ -53,6 +55,7 @@ const useRouteMock = vi.fn<() => Route>(() => PROJECT_ROUTE); const useProjectRouteWorkspaceContextMock = vi.hoisted(() => vi.fn()); const projectViewMountedMock = vi.hoisted(() => vi.fn()); const projectViewUnmountedMock = vi.hoisted(() => vi.fn()); +const projectViewLoginUpdateRequestMock = vi.hoisted(() => vi.fn()); vi.mock('../../src/router', () => ({ navigate: vi.fn(), @@ -69,17 +72,30 @@ vi.mock('../../src/collab/useProjectRouteWorkspaceContext', async (importOrigina }; }); +vi.mock('../../src/providers/daemon', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchVelaLoginStatus: vi.fn().mockResolvedValue(null) }; +}); + vi.mock('../../src/components/EntryView', () => ({ EntryView: () =>
Entry view
, })); +vi.mock('../../src/components/CloudSignInTip', async (importOriginal) => ({ + ...await importOriginal(), + CloudSignInTip: ({ onLoginSuccess }: { onLoginSuccess?: () => void }) => ( + + ), +})); + vi.mock('../../src/components/ProjectView', () => ({ - ProjectView: () => { + ProjectView: ({ onObservedPublicShareLink, loginUpdateRequest }: { loginUpdateRequest?: unknown; onObservedPublicShareLink?: (share: { projectId: string; filePath: string; slug: string; url: string; status: 'active'; workspaceId: string; workspaceMemberId: string; authorizationScopeKey: string; freshness: 'outdated' }) => void }) => { + projectViewLoginUpdateRequestMock(loginUpdateRequest ?? null); useEffect(() => { projectViewMountedMock(); return () => projectViewUnmountedMock(); }, []); - return
Project view
; + return
Project view
; }, })); @@ -378,6 +394,91 @@ describe('project route — floating account cluster', () => { }); }); + it('S13 retains only an observed active same-project link for copy when a bound route signs out', async () => { + vi.mocked(fetchVelaLoginStatus).mockResolvedValue({ + loggedIn: true, profile: 'default', configPath: '', user: { id: 'account-1', email: 'owner@example.invalid' }, + }); + const view = render(); + expect(await screen.findByText('Project view')).toBeTruthy(); + await waitFor(() => expect(fetchVelaLoginStatus).toHaveBeenCalled()); + fireEvent.click(screen.getByRole('button', { name: 'Observe active share' })); + + vi.mocked(fetchVelaLoginStatus).mockResolvedValue({ loggedIn: false, profile: 'default', configPath: '', user: null }); + useProjectRouteWorkspaceContextMock.mockReturnValue({ + context: null, loading: false, failure: 'unavailable', retry: vi.fn(), + }); + window.dispatchEvent(new Event(AMR_LOGIN_STATUS_EVENT)); + view.rerender(); + await waitFor(() => expect(screen.queryByText('Project view')).toBeNull()); + expect(screen.getByText('https://example.invalid/p/prior')).toBeTruthy(); + expect(screen.getByRole('button', { name: /copy share link/i })).toBeEnabled(); + const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + try { + fireEvent.click(screen.getByRole('button', { name: /copy share link/i })); + await waitFor(() => expect(writeText).toHaveBeenCalledExactlyOnceWith('https://example.invalid/p/prior')); + } finally { + if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard); + else Reflect.deleteProperty(navigator, 'clipboard'); + } + // Same-project explicit file route B must revoke A even with ProjectView unmounted. + useRouteMock.mockReturnValue({ ...PROJECT_ROUTE, fileName: 'other.html' }); + view.rerender(); + await waitFor(() => expect(screen.queryByText('https://example.invalid/p/prior')).toBeNull()); + useRouteMock.mockReturnValue({ ...PROJECT_ROUTE, fileName: 'index.html' }); + view.rerender(); + expect(screen.queryByText('https://example.invalid/p/prior')).toBeNull(); + useRouteMock.mockReturnValue({ ...PROJECT_ROUTE, projectId: 'another-project' }); + view.rerender(); + expect(screen.queryByText('https://example.invalid/p/prior')).toBeNull(); + }); + + it.each([ + ['same account', 'account-1', true, false], + ['different account', 'account-2', false, false], + ['same account after file B then A', 'account-1', false, true], + ] as const)('S13 explicit sign-in-to-update resumes only for %s', async (_label, accountId, allowed, switchFile) => { + vi.mocked(fetchVelaLoginStatus).mockResolvedValue({ + loggedIn: true, profile: 'default', configPath: '', user: { id: 'account-1', email: 'owner@example.invalid' }, + }); + const view = render(); + await screen.findByText('Project view'); + fireEvent.click(screen.getByRole('button', { name: 'Observe active share' })); + vi.mocked(fetchVelaLoginStatus).mockResolvedValue({ loggedIn: false, profile: 'default', configPath: '', user: null }); + useProjectRouteWorkspaceContextMock.mockReturnValue({ + context: null, loading: false, failure: 'unavailable', retry: vi.fn(), + }); + window.dispatchEvent(new Event(AMR_LOGIN_STATUS_EVENT)); + view.rerender(); + await screen.findByText('https://example.invalid/p/prior'); + expect(screen.getByRole('button', { name: /sign in to update/i })).toBeEnabled(); + fireEvent.click(screen.getByRole('button', { name: /sign in to update/i })); + if (switchFile) { + useRouteMock.mockReturnValue({ ...PROJECT_ROUTE, fileName: 'other.html' }); + view.rerender(); + await waitFor(() => expect(screen.queryByText('https://example.invalid/p/prior')).toBeNull()); + useRouteMock.mockReturnValue({ ...PROJECT_ROUTE, fileName: 'index.html' }); + view.rerender(); + } + vi.mocked(fetchVelaLoginStatus).mockResolvedValue({ + loggedIn: true, profile: 'default', configPath: '', user: { id: accountId, email: 'owner@example.invalid' }, + }); + useProjectRouteWorkspaceContextMock.mockReturnValue({ + context: PROJECT_WORKSPACE_CONTEXT, loading: false, retry: vi.fn(), + }); + window.dispatchEvent(new Event(AMR_LOGIN_STATUS_EVENT)); + view.rerender(); + await screen.findByText('Project view'); + await waitFor(() => { + const lastRequest = projectViewLoginUpdateRequestMock.mock.lastCall?.[0]; + if (allowed) expect(lastRequest).toEqual(expect.objectContaining({ + accountId: 'account-1', link: expect.objectContaining({ slug: 'prior', freshness: 'outdated' }), + })); + else expect(lastRequest).toBeNull(); + }); + }); + it('keeps the same project instance mounted through a transient authority outage and recovery', async () => { const view = render(); diff --git a/apps/web/tests/components/DesignsTab.share-delete.test.tsx b/apps/web/tests/components/DesignsTab.share-delete.test.tsx new file mode 100644 index 0000000000..48be1aa7f4 --- /dev/null +++ b/apps/web/tests/components/DesignsTab.share-delete.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { buildWorkspacePermissions, buildWorkspaceSeatSummary, type WorkspaceCollabContext } from '@open-design/contracts'; +import { DesignsTab } from '../../src/components/DesignsTab'; +import type { Project } from '../../src/types'; + +const context: WorkspaceCollabContext = { + workspaceId: 'ws', workspaceType: 'personal', workspaceMemberId: 'member', role: 'owner', + memberStatus: 'active', lifecycleState: 'active', billingState: 'active', planId: null, + providerMode: 'platform_credits', seatSummary: buildWorkspaceSeatSummary({ seatLimit: 1, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }), +}; +let workspaceContext: WorkspaceCollabContext | null = context; +vi.mock('../../src/collab/useWorkspaceContext', () => ({ useWorkspaceContext: () => ({ context: workspaceContext, loading: false }) })); +vi.mock('../../src/providers/registry', () => ({ + deleteLiveArtifact: vi.fn(), fetchLiveArtifacts: vi.fn(async () => []), + fetchProjectFiles: vi.fn(async () => []), liveArtifactPreviewUrl: () => '/preview', +})); + +const project: Project = { + id: 'project-1', name: 'Landing refresh', skillId: null, designSystemId: null, + createdAt: 1, updatedAt: 2, status: { value: 'not_started' }, +}; +const remove = vi.fn(async (_id: string) => true); +const request = vi.fn(); + +beforeEach(() => { + workspaceContext = context; + remove.mockClear(); + request.mockReset(); + window.localStorage.clear(); + vi.stubGlobal('fetch', request); + request.mockImplementation(async (url) => String(url).includes('/share-state') + ? Response.json({ projectId: project.id, bindingExists: true, hasEverShared: true, publications: [ + { sourceFilePath: 'index.html', slug: 'first', status: 'active' }, + { sourceFilePath: 'other.html', slug: 'second', status: 'active' }, + { sourceFilePath: 'old.html', slug: 'old', status: 'stopped' }, + ] }) + : new Response('', { status: 200 })); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +it('All Projects card warns about every active link, cancellation preserves the project, and confirmation deletes once', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'More actions' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete' })); + expect(await screen.findByText('This project has 2 pages being shared. Deleting it will disable their share links, and visitors will no longer be able to open them.')).toBeVisible(); + expect(request).toHaveBeenCalledWith('/api/projects/project-1/share-state', expect.objectContaining({ headers: expect.objectContaining({ 'x-od-workspace-id': 'ws' }) })); + fireEvent.click(screen.getByTestId('project-delete-confirm-cancel')); + expect(remove).not.toHaveBeenCalled(); + expect(screen.queryByRole('alertdialog')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'More actions' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete' })); + expect(await screen.findByText('This project has 2 pages being shared. Deleting it will disable their share links, and visitors will no longer be able to open them.')).toBeVisible(); + fireEvent.click(screen.getByTestId('project-delete-confirm-accept')); + await waitFor(() => expect(remove).toHaveBeenCalledExactlyOnceWith(project.id)); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); +}); + +it('All Projects batch checks both selected projects and warns of the aggregate active-link count before deletion', async () => { + const other: Project = { ...project, id: 'project-2', name: 'Brand system', createdAt: 3, updatedAt: 4 }; + request.mockImplementation(async (url) => { + const path = String(url); + if (path.endsWith('/share-state')) { + const id = path.split('/')[3]; + return Response.json({ projectId: id, bindingExists: true, hasEverShared: true, publications: id === project.id + ? [{ sourceFilePath: 'index.html', slug: 'first', status: 'active' }, { sourceFilePath: 'old.html', slug: 'old', status: 'stopped' }] + : [{ sourceFilePath: 'other.html', slug: 'second', status: 'active' }, { sourceFilePath: 'third.html', slug: 'third', status: 'active' }] }); + } + return new Response('', { status: 200 }); + }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Select' })); + fireEvent.click(screen.getByText('Landing refresh').closest('.design-card') as HTMLElement); + fireEvent.click(screen.getByText('Brand system').closest('.design-card') as HTMLElement); + fireEvent.click(screen.getByRole('button', { name: 'Delete selected' })); + const dialog = screen.getByRole('alertdialog'); + expect(await screen.findByText('This project has 3 pages being shared. Deleting it will disable their share links, and visitors will no longer be able to open them.')).toBeVisible(); + expect(remove).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + expect(remove).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Delete selected' })); + expect(await screen.findByText('This project has 3 pages being shared. Deleting it will disable their share links, and visitors will no longer be able to open them.')).toBeVisible(); + fireEvent.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Delete selected' })); + await waitFor(() => expect(remove).toHaveBeenCalledTimes(2)); + expect(remove.mock.calls.map(([id]) => id).sort()).toEqual([project.id, other.id]); + expect(dialog.isConnected).toBe(false); +}); + +it('All Projects batch never deletes when workspace context is missing and no share count is known', () => { + workspaceContext = null; + const other: Project = { ...project, id: 'project-2', name: 'Brand system' }; + render(); + fireEvent.click(screen.getByRole('button', { name: 'Select' })); + fireEvent.click(screen.getByText('Landing refresh').closest('.design-card') as HTMLElement); + fireEvent.click(screen.getByText('Brand system').closest('.design-card') as HTMLElement); + fireEvent.click(screen.getByRole('button', { name: 'Delete selected' })); + const dialog = screen.getByRole('alertdialog'); + expect(within(dialog).getByRole('button', { name: 'Delete selected' })).toBeDisabled(); + expect(within(dialog).getByRole('alert')).toBeVisible(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete selected' })); + expect(remove).not.toHaveBeenCalled(); + expect(request.mock.calls.filter(([url]) => String(url).endsWith('/share-state'))).toHaveLength(0); +}); +it('never treats one failed project share-state read as zero links or deletes a partial batch', async () => { + const other: Project = { ...project, id: 'project-2', name: 'Brand system' }; + request.mockImplementation(async (url) => String(url).includes('/project-2/share-state') + ? new Response('', { status: 503 }) + : String(url).includes('/share-state') + ? Response.json({ projectId: project.id, bindingExists: true, hasEverShared: true, + publications: [{ sourceFilePath: 'index.html', slug: 'first', status: 'active' }] }) + : new Response('', { status: 200 })); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Select' })); + fireEvent.click(screen.getByText('Landing refresh').closest('.design-card') as HTMLElement); + fireEvent.click(screen.getByText('Brand system').closest('.design-card') as HTMLElement); + fireEvent.click(screen.getByRole('button', { name: 'Delete selected' })); + const dialog = screen.getByRole('alertdialog'); + expect(await within(dialog).findByRole('alert')).toBeVisible(); + const accept = within(dialog).getByRole('button', { name: 'Delete selected' }); + expect(accept).toBeDisabled(); + expect(within(dialog).queryByText(/pages being shared/)).toBeNull(); + fireEvent.click(accept); + expect(remove).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + expect(remove).not.toHaveBeenCalled(); +}); diff --git a/apps/web/tests/components/FileOpsSummary.test.tsx b/apps/web/tests/components/FileOpsSummary.test.tsx index 49d0cb11f2..30ec5ba244 100644 --- a/apps/web/tests/components/FileOpsSummary.test.tsx +++ b/apps/web/tests/components/FileOpsSummary.test.tsx @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { CollabProvider, useProjectCollabContext } from '../../src/collab/collab-context'; import { FileOpsSummary } from '../../src/components/FileOpsSummary'; +import { ArtifactPublicationContext } from '../../src/components/share/ArtifactPublicationContext'; import type { FileOpEntry } from '../../src/runtime/file-ops'; function entry(partial: Partial & { path: string }): FileOpEntry { @@ -293,6 +294,28 @@ describe('FileOpsSummary artifact cards', () => { ); }); + it('shows the current file as shared after confirmed publication, retains the panel trigger, and clears on stop', () => { + const onPublish = vi.fn(); + const props = { entries: [entry({ path: 'result.html' })], projectId: 'proj-1', onPublish, onExport: vi.fn() }; + const renderCard = (paths: string[] | null) => ; + const view = render(renderCard([])); + const share = screen.getByTestId('artifact-card-publish-result.html'); + expect(share).toHaveTextContent('Share'); + view.rerender(renderCard(['other.html'])); + expect(share).toHaveTextContent('Share'); + view.rerender(renderCard(['result.html'])); + expect(share).toHaveTextContent('Shared'); + expect(share).toBeEnabled(); + fireEvent.click(share); + expect(onPublish).toHaveBeenCalledExactlyOnceWith('result.html', share.getAttribute('data-artifact-anchor')); + view.rerender(renderCard([])); + expect(share).toHaveTextContent('Share'); + view.rerender(renderCard(null)); + expect(share).toHaveTextContent('Status unknown'); + expect(share).toHaveAccessibleName('Share · Status unknown'); + expect(share).toBeEnabled(); + }); + it('uses production collab authority to disable Share without removing its anchor', () => { const onPublish = vi.fn(); function ReadonlyCard() { diff --git a/apps/web/tests/components/FileViewer.after-export-guide-integration.test.tsx b/apps/web/tests/components/FileViewer.after-export-guide-integration.test.tsx new file mode 100644 index 0000000000..3da22a4414 --- /dev/null +++ b/apps/web/tests/components/FileViewer.after-export-guide-integration.test.tsx @@ -0,0 +1,278 @@ +// @vitest-environment jsdom +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resetWorkspaceAccountGeneration } from "../../src/collab/workspace-identity"; +import { FileViewer } from "../../src/components/FileViewer"; +import type { ProjectFile } from "../../src/types"; + +const seam = vi.hoisted(() => ({ exportHtml: vi.fn(), guideInput: vi.fn() })); +vi.mock("../../src/runtime/exports", async () => ({ + ...(await vi.importActual("../../src/runtime/exports")), + exportProjectAsHtml: seam.exportHtml, +})); +// Observe the input but run the real guide hook and its account-scoped storage/clock. +vi.mock("../../src/components/share/useAfterExportShareGuide", async () => { + const actual = await vi.importActual< + typeof import("../../src/components/share/useAfterExportShareGuide") + >("../../src/components/share/useAfterExportShareGuide"); + return { + ...actual, + useAfterExportShareGuide: ( + input: Parameters[0], + ) => { + seam.guideInput(input); + return actual.useAfterExportShareGuide(input); + }, + }; +}); + +const file: ProjectFile = { + name: "index.html", + path: "index.html", + type: "file", + size: 100, + mtime: 1, + kind: "html", + mime: "text/html", + artifactManifest: { + version: 1, + kind: "html", + title: "Page", + entry: "index.html", + renderer: "html", + exports: ["html"], + }, +}; +const request = vi.fn(); +const account = { + loggedIn: true, + profile: "test", + configPath: "", + user: { id: "p1-account", email: "fixture@example.invalid" }, +}; +const history = { + projectId: "project-guide", + bindingExists: false, + hasEverShared: false, + publications: [], +}; + +beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + resetWorkspaceAccountGeneration(); + request.mockImplementation(async (input) => { + const url = String(input); + if (url.startsWith("/api/integrations/vela/status")) + return Response.json(account); + if (url === "/api/projects/project-guide/share-state") + return Response.json(history); + return Response.json({ deployments: [] }); + }); + vi.stubGlobal("fetch", request); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + window.localStorage.clear(); + resetWorkspaceAccountGeneration(); +}); + +async function readyViewer() { + const view = render( + , + ); + await waitFor(() => + expect(seam.guideInput).toHaveBeenCalledWith( + expect.objectContaining({ + appUserId: "p1-account", + hasEverShared: false, + enabled: true, + }), + ), + ); + return view; +} +async function exportHtml() { + fireEvent.click(await screen.findByRole("button", { name: /^Export$/ })); + fireEvent.click( + await screen.findByRole("menuitem", { name: /Export as standalone HTML/i }), + ); +} + +describe("Owner P1 real FileViewer export-to-share chain", () => { + it("offers no guide until the real daemon HTML export downloads, then opens the Share tab", async () => { + const actual = await vi.importActual< + typeof import("../../src/runtime/exports") + >("../../src/runtime/exports"); + seam.exportHtml.mockImplementation(actual.exportProjectAsHtml); + const download = { blob: null as Blob | null }; + const OriginalURL = URL; + vi.stubGlobal( + "URL", + Object.assign(class extends OriginalURL {}, { + createObjectURL: (blob: Blob) => { + download.blob = blob; + return "blob:export-proof"; + }, + revokeObjectURL: vi.fn(), + }), + ); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + let finishExport!: (response: Response) => void; + const regularRequest = request.getMockImplementation(); + if (!regularRequest) throw new Error("missing P1 HTTP fixture"); + request.mockImplementation((input, init) => + String(input) === "/api/projects/project-guide/export/html" + ? new Promise((resolve) => { + finishExport = resolve; + }) + : regularRequest(input, init), + ); + await readyViewer(); + expect( + screen.queryByRole("status", { name: "Export complete" }), + ).toBeNull(); + await exportHtml(); + await waitFor(() => + expect(request).toHaveBeenCalledWith( + "/api/projects/project-guide/export/html", + expect.objectContaining({ method: "POST" }), + ), + ); + expect( + screen.queryByRole("status", { name: "Export complete" }), + ).toBeNull(); + await act(async () => { + finishExport( + new Response("

downloaded

", { + status: 200, + headers: { "content-type": "text/html" }, + }), + ); + }); + const guide = await screen.findByRole("status", { + name: "Export complete", + }); + if (!download.blob) throw new Error("real HTML export did not download"); + expect(await download.blob.text()).toContain("downloaded"); + expect(within(guide).getByRole("button", { name: "Share" })).toBeEnabled(); + fireEvent.click(within(guide).getByRole("button", { name: "Share" })); + expect( + screen.queryByRole("status", { name: "Export complete" }), + ).toBeNull(); + expect( + await screen.findByRole("button", { name: "More sharing options" }), + ).toBeVisible(); + }); + + it("dismisses only this guide, offers it after the next success, then persists Never show again", async () => { + seam.exportHtml.mockResolvedValue(undefined); + const view = await readyViewer(); + await exportHtml(); + const first = await screen.findByRole("status", { + name: "Export complete", + }); + // Guide close is time-driven; clicking its Share action dismisses only this instance. + fireEvent.click(within(first).getByRole("button", { name: "Share" })); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + await exportHtml(); + const again = await screen.findByRole("status", { + name: "Export complete", + }); + fireEvent.click( + within(again).getByRole("button", { name: "Don't show again" }), + ); + expect( + screen.queryByRole("status", { name: "Export complete" }), + ).toBeNull(); + expect( + window.localStorage.getItem("od:after-export-share-guide:v1:p1-account"), + ).toBe("1"); + view.unmount(); + await readyViewer(); + await exportHtml(); + await act(async () => { + await Promise.resolve(); + }); + expect( + screen.queryByRole("status", { name: "Export complete" }), + ).toBeNull(); + }); + + it("automatically closes only the current guide after ten seconds and reoffers after a new success", async () => { + seam.exportHtml.mockResolvedValue(undefined); + await readyViewer(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + fireEvent.click(screen.getByRole("button", { name: /^Export$/ })); + await act(async () => { + fireEvent.click( + screen.getByRole("menuitem", { name: /Export as standalone HTML/i }), + ); + await Promise.resolve(); + }); + expect( + screen.getByRole("status", { name: "Export complete" }), + ).toBeVisible(); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect( + screen.queryByRole("status", { name: "Export complete" }), + ).toBeNull(); + vi.useRealTimers(); + await exportHtml(); + expect( + await screen.findByRole("status", { name: "Export complete" }), + ).toBeVisible(); + }); + + it.each(["cancelled", "failed"] as const)( + "never offers the guide after completed %s export", + async (outcome) => { + let finish!: (result: string) => void; + let fail!: (error: Error) => void; + seam.exportHtml.mockImplementation( + () => + new Promise((resolve, reject) => { + finish = resolve; + fail = reject; + }), + ); + await readyViewer(); + await exportHtml(); + expect( + screen.queryByRole("status", { name: "Export complete" }), + ).toBeNull(); + await act(async () => { + if (outcome === "cancelled") finish("cancelled"); + else fail(new Error("export failed")); + }); + if (outcome === "failed") { + await screen.findByText("export failed"); + } else { + await waitFor(() => + expect(screen.queryByText(/Exporting/i)).toBeNull(), + ); + } + expect( + screen.queryByRole("status", { name: "Export complete" }), + ).toBeNull(); + }, + ); +}); diff --git a/apps/web/tests/components/FileViewer.publish-tracking.test.tsx b/apps/web/tests/components/FileViewer.publish-tracking.test.tsx index b5355cf36d..b3e32f39d5 100644 --- a/apps/web/tests/components/FileViewer.publish-tracking.test.tsx +++ b/apps/web/tests/components/FileViewer.publish-tracking.test.tsx @@ -177,9 +177,9 @@ function reactComponentFile(): ProjectFile { // The publish trigger is the Share panel's `role="menuitem"` row labelled by // `fileViewer.publishSingleFileTitle`; once published it is replaced by the -// copy-link / `fileViewer.unpublishFile` pair. +// copy-link control and the single link-access switch. const PUBLISH_ROW = /generate and copy link|get a share link/i; -const UNPUBLISH_ROW = /stop sharing/i; +const UNPUBLISH_ROW = /link access/i; // HTML uses `fileViewer.uploadingFile`; the legacy React card still uses // `fileViewer.publishingFile`. Both labels identify the in-flight row, and the // state the publish handler leaves behind only once its `finally` has run. @@ -200,19 +200,6 @@ async function openPublishPanel() { return await screen.findByRole('menuitem', { name: PUBLISH_ROW }); } -// Same flow through the ReactComponentViewer copy of the publish card, which -// is hand-duplicated from HtmlViewer and can regress independently. -async function openReactComponentPublishPanel() { - renderProjectFileViewer(teamWorkspaceContext(), { - projectId: 'project-pub', - projectKind: 'prototype', - file: reactComponentFile(), - }); - const shareButton = await screen.findByRole('button', { name: /^share$/i }); - fireEvent.click(shareButton); - return await screen.findByRole('menuitem', { name: PUBLISH_ROW }); -} - afterEach(() => { cleanup(); vi.unstubAllGlobals(); @@ -291,7 +278,7 @@ describe('publish flow analytics', () => { const publishButton = await openPublishPanel(); fireEvent.click(publishButton); - const unpublishButton = await screen.findByRole('button', { name: UNPUBLISH_ROW }); + const unpublishButton = await screen.findByRole('switch', { name: UNPUBLISH_ROW }); fireEvent.click(unpublishButton); await waitFor(() => { expect(trackedEvents('artifact_publish_result')).toContainEqual( @@ -307,7 +294,7 @@ describe('publish flow analytics', () => { it('S10 keeps the link and retry action without claiming a clipboard failure when stopping fails', async () => { const fetchMock = stubFetch({ unpublishStatus: 500 }); fireEvent.click(await openPublishPanel()); - const stop = await screen.findByRole('button', { name: UNPUBLISH_ROW }); + const stop = await screen.findByRole('switch', { name: UNPUBLISH_ROW }); fireEvent.click(stop); await screen.findByText('Could not turn off the link. Please try again.'); expect(stop).toBeEnabled(); @@ -328,7 +315,7 @@ describe('publish flow analytics', () => { const publishButton = await openPublishPanel(); fireEvent.click(publishButton); - const unpublishButton = await screen.findByRole('button', { name: UNPUBLISH_ROW }); + const unpublishButton = await screen.findByRole('switch', { name: UNPUBLISH_ROW }); fireEvent.click(unpublishButton); await waitFor(() => { expect(trackedEvents('artifact_publish_result')).toContainEqual( @@ -427,14 +414,30 @@ describe('publish flow analytics', () => { }, 'HtmlViewer', ); - inactiveViewerCase( - { projectId: 'project-pub', projectKind: 'prototype', file: reactComponentFile() }, - 'ReactComponentViewer', - ); + it('does not publish or report publish analytics for a React component, even when retained', async () => { + const fetchMock = stubFetch(); + const props: ComponentProps = { + projectId: 'project-pub', + projectKind: 'prototype', + file: reactComponentFile(), + }; + const { rerenderWith } = renderProjectFileViewer(teamWorkspaceContext(), props); + const shareButton = await screen.findByRole('button', { name: /^share$/i }); + await waitFor(() => expect(shareButton).toBeDisabled()); + fireEvent.click(shareButton); + expect(screen.queryByRole('menuitem', { name: PUBLISH_ROW })).toBeNull(); - it('reports the project kind and artifact kind from the ReactComponentViewer copy of the flow', async () => { + rerenderWith({ ...props, workspaceActive: false }); + rerenderWith({ ...props, workspaceActive: true }); + expect(await screen.findByRole('button', { name: /^share$/i })).toBeDisabled(); + expect(fetchMock.mock.calls.some(([input]) => String(input).includes('publish-public'))).toBe(false); + expect(trackedEvents('ui_click').filter((event) => event.element === 'publish_file')).toEqual([]); + expect(trackedEvents('artifact_publish_result')).toEqual([]); + }); + + it('reports the project kind and artifact kind from a supported HTML publication', async () => { stubFetch(); - const publishButton = await openReactComponentPublishPanel(); + const publishButton = await openPublishPanel(); fireEvent.click(publishButton); expect(trackedEvents('ui_click')).toContainEqual( @@ -539,7 +542,7 @@ describe('publish failure detail analytics', () => { }, }); fireEvent.click(await openPublishPanel()); - fireEvent.click(await screen.findByRole('button', { name: UNPUBLISH_ROW })); + fireEvent.click(await screen.findByRole('switch', { name: UNPUBLISH_ROW })); await waitFor(() => { expect(trackedEvents('artifact_publish_result')).toContainEqual( diff --git a/apps/web/tests/components/FileViewer.share-request-replay.test.tsx b/apps/web/tests/components/FileViewer.share-request-replay.test.tsx index e819bb0fe1..04ff9df6c5 100644 --- a/apps/web/tests/components/FileViewer.share-request-replay.test.tsx +++ b/apps/web/tests/components/FileViewer.share-request-replay.test.tsx @@ -24,6 +24,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useState } from 'react'; import { FileOpsSummary } from '../../src/components/FileOpsSummary'; +import { nextShareRequestNonce } from '../../src/components/share-request-nonce'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { FileViewer } from '../../src/components/FileViewer'; @@ -160,6 +161,45 @@ describe('产物卡 Share / Export 请求是可反复开关的入口', () => { expect(document.querySelectorAll('.chrome-unified-panel--share')).toHaveLength(1); }); + it('F13 real artifact-card Share reopens after the panel Close button at the same millisecond', async () => { + stubFetch(); + vi.spyOn(Date, 'now').mockReturnValue(1710000000000); + const seen: number[] = []; + function CardAndViewer() { + const [request, setRequest] = useState<{ nonce: number; anchorId: string } | null>(null); + return <> + { + expect(name).toBe('index.html'); + setRequest(previous => { + const nonce = nextShareRequestNonce(previous?.nonce, Date.now()); + seen.push(nonce); + return { nonce, anchorId }; + }); + }} + /> + {viewerWithActionRequest({ shareRequest: request })} + ; + } + render(); + const cardShare = screen.getByTestId('artifact-card-publish-index.html'); + cardShare.getBoundingClientRect = () => ({ x: 220, y: 300, left: 220, top: 300, right: 278, bottom: 328, width: 58, height: 28, toJSON: () => ({}) }); + fireEvent.click(cardShare); + await waitFor(() => expect(anchoredMenu()).not.toBeNull()); + expect(document.querySelectorAll('.chrome-unified-panel--share')).toHaveLength(1); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(anchoredMenu()).toBeNull()); + expect(document.querySelectorAll('.chrome-unified-panel--share')).toHaveLength(0); + fireEvent.click(cardShare); + await waitFor(() => expect(anchoredMenu()).not.toBeNull()); + expect(document.querySelectorAll('.chrome-unified-panel--share')).toHaveLength(1); + expect(seen).toEqual([1710000000000, 1710000000001]); + expect(document.querySelector('.social-share-grid')).toBeNull(); + }); + it('Share 同一枚入口点第二次关闭,第三次可再打开', async () => { stubFetch(); const anchorId = 'publish:index.html'; @@ -246,18 +286,9 @@ describe('分享请求只许消费一次 —— 重挂之后不许重放', () => }); }); -/* ------------------------------------------------------------------ * - * 锚点 id 也是「打开」状态的一部分,关掉时要一起清 - * ------------------------------------------------------------------ * - * `menuAnchorId` 决定菜单开在哪儿:有值 = 贴着产物卡上那枚胶囊,null = 原地长在 - * 工具栏下面。它是在卡片那条路上设的,但**从来没有人把它清回 null**。 - * - * 于是卡上开过一次之后,再点**工具栏**的 Share,菜单会去找那枚卡上的按钮 —— - * 卡还在就开在卡上(点工具栏却在别处弹出来),卡滚走了就 `findAnchor` 落空、 - * 什么都不画(点了没反应)。两种都是用户说的「这里重新显示会有 bug」。 - */ -describe('工具栏那条路必须开在工具栏,不受上一次卡片锚点影响', () => { - it('卡上开过一次之后,点工具栏 Share 要开在**原地**,不是又贴回卡上', async () => { +/* 工具栏入口必须开在自身旁边,而不是复用上次的产物卡锚点。 */ +describe('工具栏入口切换锚点', () => { + it('卡上开过一次之后,点工具栏 Share 在工具栏旁打开顶层浮层', async () => { stubFetch(); renderViewer({ nonce: 1730000000000, anchorId: 'publish:index.html' }); // 卡上那枚按钮不在 DOM 里(聊天流没渲染),锚点落空 —— 菜单不画 @@ -268,9 +299,10 @@ describe('工具栏那条路必须开在工具栏,不受上一次卡片锚点影 shareBtn.click(); await waitFor(() => expect(menu(), '点了工具栏却没开出菜单').not.toBeNull()); - expect( - document.querySelector('[data-anchored-menu]'), - '工具栏点开的菜单却 portal 到了卡片锚点上', - ).toBeNull(); + const portal = document.querySelector('[data-anchored-menu]'); + expect(portal).not.toBeNull(); + expect(portal?.parentElement).toBe(document.body); + expect(portal?.getAttribute('data-anchored-menu')).toBe(shareBtn.getAttribute('data-artifact-anchor')); + expect(portal?.getAttribute('data-anchored-menu')).not.toBe('publish:index.html'); }); }); diff --git a/apps/web/tests/components/FileViewer.share-size-limit.test.tsx b/apps/web/tests/components/FileViewer.share-size-limit.test.tsx index 7f811be9a4..56d84aba71 100644 --- a/apps/web/tests/components/FileViewer.share-size-limit.test.tsx +++ b/apps/web/tests/components/FileViewer.share-size-limit.test.tsx @@ -3,10 +3,12 @@ // Exercise the real fetch/provider error parsing, not a synthetic thrown error. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -import React, { type ComponentProps } from 'react'; +import type { ComponentProps } from 'react'; import { buildWorkspacePermissions, buildWorkspaceSeatSummary, + SHARE_MAX_TOTAL_BYTES, + type SharePlanSummary, type WorkspaceCollabContext, } from '@open-design/contracts'; @@ -104,7 +106,7 @@ function renderProjectFileViewer( } function stubFetch( - options: { publishStatus?: number; publishBody?: unknown; unpublishStatus?: number } = {}, + options: { publishStatus?: number; publishBody?: unknown; unpublishStatus?: number; sharePlan?: () => SharePlanSummary } = {}, ) { const { publishStatus = 200, publishBody, unpublishStatus = 200 } = options; const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { @@ -112,6 +114,9 @@ function stubFetch( if (url.includes('/api/workspace/context')) { return new Response(JSON.stringify({ context: teamWorkspaceContext() }), { status: 200 }); } + if (url.includes('/share-plan') && options.sharePlan) { + return Response.json(options.sharePlan()); + } if (url.includes('publish-public')) { if (init?.method === 'POST') { const body = @@ -168,6 +173,46 @@ async function setup(options: Parameters[0] = { publishBody: p vi.useFakeTimers(); return { fetch, view }; } +it('S15 blocks an over-limit planned file before publish, then rechecks a smaller file on reopen', async () => { + let totalBytes = SHARE_MAX_TOTAL_BYTES + 1; + const fetch = stubFetch({ sharePlan: () => ({ fileCount: 1, totalBytes, exceedsSizeLimit: totalBytes > SHARE_MAX_TOTAL_BYTES, exclusions: [] }) }); + renderProjectFileViewer(teamWorkspaceContext(), props); + fireEvent.click(await screen.findByRole('button', { name: /^share$/i })); + const generate = await screen.findByRole('menuitem', { name: /generate and copy link/i }); + const warning = await screen.findByRole('alert'); + expect(warning).toHaveTextContent('20 MiB'); + expect(warning).toHaveTextContent('20.00 MiB'); + expect(generate).toBeDisabled(); + fireEvent.click(generate); + expect(posts(fetch)).toHaveLength(0); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + totalBytes = SHARE_MAX_TOTAL_BYTES; + fireEvent.click(await screen.findByRole('button', { name: /^share$/i })); + const reopenedGenerate = await screen.findByRole('menuitem', { name: /generate and copy link/i }); + await vi.waitFor(() => expect(reopenedGenerate).toBeEnabled()); + expect(posts(fetch)).toHaveLength(0); + fireEvent.click(reopenedGenerate); + await vi.waitFor(() => expect(posts(fetch)).toHaveLength(1)); +}); + +it('S1 opens with selected link access but makes no public HTTP write until Generate and copy', async () => { + const { fetch } = await setup(); + const toggle = screen.getByRole('switch', { name: /link access/i }); + const generate = screen.getByRole('menuitem', { name: /generate and copy link/i }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(screen.queryByText(publication.url)).toBeNull(); + expect(posts(fetch)).toHaveLength(0); + expect(write).not.toHaveBeenCalled(); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(generate).toBeDisabled(); + expect(posts(fetch)).toHaveLength(0); + fireEvent.click(toggle); + await publish(); + expect(posts(fetch)).toHaveLength(1); + expect(write).toHaveBeenCalledWith(publication.url); +}); + it('keeps publish inspectable but inert while streaming and restores it after completion', async () => { const fetch = stubFetch({ publishBody: publication }); const view = renderProjectFileViewer(teamWorkspaceContext(), { ...props, streaming: true }); @@ -206,6 +251,38 @@ it('does not auto-copy a pending publication when generation has resumed', async expect((screen.getByRole('button', { name: /copy share link/i }) as HTMLButtonElement).disabled).toBe(true); }); +it('manual published-link copy keeps URL through pending, copied timer, failure and retry', async () => { + const { fetch } = await setup(); + await publish(); + expect(write).toHaveBeenCalledTimes(1); + await tick(1800); + const pendingCopy = deferred(); + write.mockImplementationOnce(() => pendingCopy.promise); + fireEvent.click(screen.getByRole('button', { name: /copy share link/i })); + const copying = screen.getByRole('button', { name: /copying/i }); + expect(copying).toBeDisabled(); + expect(copying).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByText(publication.url)).toBeTruthy(); + fireEvent.click(copying); + expect(write).toHaveBeenCalledTimes(2); + await act(async () => pendingCopy.resolve()); + expect(screen.getByRole('button', { name: /^copied$/i })).toBeTruthy(); + await tick(1799); + expect(screen.getByRole('button', { name: /^copied$/i })).toBeTruthy(); + await tick(1); + expect(screen.getByRole('button', { name: /copy share link/i })).toBeTruthy(); + + write.mockRejectedValueOnce(new Error(sensitive)); + await act(async () => fireEvent.click(screen.getByRole('button', { name: /copy share link/i }))); + expect(screen.getByText(publication.url)).toBeTruthy(); + expect(screen.getByText(/manually copy/i)).toBeTruthy(); + expect(screen.getByRole('button', { name: /copy share link/i })).toBeEnabled(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: /copy share link/i }))); + expect(screen.getByRole('button', { name: /^copied$/i })).toBeTruthy(); + expect(write).toHaveBeenCalledTimes(4); + expect(posts(fetch)).toHaveLength(1); +}); + async function publish() { await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: /generate and copy link/i })); @@ -249,6 +326,35 @@ it('real 413 too_large ends progress, explains the limit and retries only on dem expect(write.mock.calls).toEqual([[publication.url]]); expect(screen.queryByText(sizeMessage)).toBeNull(); }); +it('retries a generic publish failure once and shows the recovered URL', async () => { + const { fetch } = await setup({ publishStatus: 500, publishBody: { error: { message: sensitive } } }); + await publish(); + const failure = screen.getByText(genericMessage).closest('[role="status"]'); + expect(failure).toBeTruthy(); + expect(failure).toHaveTextContent(genericMessage); + expect(failure?.querySelector('svg circle')).toBeTruthy(); + expect(document.body.textContent).not.toContain(sensitive); + expect(document.querySelector('progress')).toBeNull(); + expect(retry()).toBeEnabled(); + expect(retry().querySelector('svg path')?.getAttribute('d')).toContain('M20 7v5h-5'); + expect(write).not.toHaveBeenCalled(); + expect(posts(fetch)).toHaveLength(1); + await tick(10_000); + expect(posts(fetch)).toHaveLength(1); + + fetch.mockImplementationOnce(async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).includes('publish-public') && init?.method === 'POST') { + return new Response(JSON.stringify(publication), { status: 200 }); + } + return new Response(JSON.stringify({ deployments: [] }), { status: 200 }); + }); + await act(async () => fireEvent.click(retry())); + expect(screen.queryByText(genericMessage)).toBeNull(); + expect(screen.getByText(publication.url)).toBeTruthy(); + expect(posts(fetch)).toHaveLength(2); + expect(posts(fetch)[1]![0]).toBe(posts(fetch)[0]![0]); + expect(write.mock.calls).toEqual([[publication.url]]); +}); it.each([[413, { error: 'unknown_limit', message: sensitive }], [500, { error: 'too_large' }]])( 'does not misclassify status %s or an unknown 413', async (publishStatus, publishBody) => { await setup({ publishStatus, publishBody }); @@ -259,6 +365,28 @@ it.each([[413, { error: 'unknown_limit', message: sensitive }], [500, { error: ' expect(document.body.textContent).not.toContain(sensitive); expect(write).not.toHaveBeenCalled(); }); +it('S15 closes an over-limit panel and permits a fresh publish after the same HTML is reduced', async () => { + const { view, fetch } = await setup({ publishStatus: 413, publishBody: tooLarge }); + await publish(); + expect(screen.getByText(sizeMessage)).toBeTruthy(); + expect(posts(fetch)).toHaveLength(1); + fireEvent.click(screen.getByRole('button', { name: /^share$/i })); + await act(async () => view.rerenderWith({ ...props, file: { ...htmlFile(), + size: 512, mtime: htmlFile().mtime + 1 }, + liveHtml: 'smaller', + })); + expect(posts(fetch)).toHaveLength(1); // Editing a failed package must not auto-upload. + fireEvent.click(screen.getByRole('button', { name: /^share$/i })); + expect(screen.queryByText(sizeMessage)).toBeNull(); + await tick(0); // The same-file source revision revalidates the share-plan GET. + expect(posts(fetch)).toHaveLength(1); // Reopening only offers a fresh manual attempt. + expect(screen.getByRole('menuitem', { name: /generate and copy link/i })).toBeEnabled(); + fetch.mockImplementationOnce(async () => new Response(JSON.stringify(publication), { status: 200 })); + await publish(); + expect(posts(fetch)).toHaveLength(2); + expect(screen.getByText(publication.url)).toBeTruthy(); + expect(write).toHaveBeenCalledExactlyOnceWith(publication.url); +}); it.each(['file', 'project'] as const)('clears over-limit error when changing %s', async change => { const { view, fetch } = await setup({ publishStatus: 413, publishBody: tooLarge }); await publish(); @@ -282,6 +410,14 @@ it('keeps bounded progress across panel reopen and copies only the completed res expect(request[0]).toBe('/api/projects/s15-project/files/index.html/publish-public'); expect(request[1]).toMatchObject({ method: 'POST' }); expect(request[1]?.body).toBeUndefined(); + const pendingSwitch = screen.getByRole('switch', { name: /link access/i }); + const pendingAction = screen.getByRole('menuitem', { name: /uploading/i }); + expect(pendingSwitch).toBeDisabled(); + expect(pendingAction).toBeDisabled(); + expect(pendingAction).toHaveAttribute('aria-busy', 'true'); + fireEvent.click(pendingSwitch); + fireEvent.click(pendingAction); + expect(posts(fetch)).toHaveLength(1); let previous = 0; for (const elapsed of [250, 1000, 5000, 60000]) { await tick(elapsed); @@ -299,15 +435,16 @@ it('keeps bounded progress across panel reopen and copies only the completed res expect(document.querySelector('progress')!.value).toBeGreaterThanOrEqual(previous); expect(posts(fetch)).toHaveLength(1); await act(async () => gate.resolve(new Response(JSON.stringify(publication)))); - expect(document.querySelector('progress')!.value).toBe(1); - expect(write.mock.calls).toEqual([[publication.url]]); - expect(screen.getByRole('button', { name: /^copied!$/i })).toBeTruthy(); - await tick(999); - expect(document.querySelector('progress')!.value).toBe(1); - await tick(1); expect(document.querySelector('progress')).toBeNull(); + expect(screen.getByText(publication.url)).toBeTruthy(); + expect(write.mock.calls).toEqual([[publication.url]]); + expect(screen.getByRole('button', { name: /^copied$/i })).toBeTruthy(); + await tick(999); + expect(document.querySelector('progress')).toBeNull(); + await tick(1); + expect(screen.getByText(publication.url)).toBeTruthy(); await tick(799); - expect(screen.getByRole('button', { name: /^copied!$/i })).toBeTruthy(); + expect(screen.getByRole('button', { name: /^copied$/i })).toBeTruthy(); await tick(1); expect(screen.getByRole('button', { name: /copy share link/i })).toBeTruthy(); expect(posts(fetch)).toHaveLength(1); @@ -317,7 +454,7 @@ it('clipboard rejection preserves the successful publication and its existing co await setup(); write.mockRejectedValueOnce(new Error(sensitive)); await publish(); - expect(screen.getByRole('button', { name: /stop sharing/i })).toBeTruthy(); + expect(screen.getByRole('switch', { name: /link access/i })).toHaveAttribute('aria-checked', 'true'); expect(screen.getByText(publication.url)).toBeTruthy(); expect(screen.getByText(/could not copy automatically.*manually copy/i).getAttribute('role')).toBe('status'); expect(screen.getByRole('button', { name: /copy share link/i })).toBeTruthy(); diff --git a/apps/web/tests/components/FileViewer.share-tab-extraction.test.tsx b/apps/web/tests/components/FileViewer.share-tab-extraction.test.tsx index 2b0bfbdf86..383f1fc8c5 100644 --- a/apps/web/tests/components/FileViewer.share-tab-extraction.test.tsx +++ b/apps/web/tests/components/FileViewer.share-tab-extraction.test.tsx @@ -18,7 +18,7 @@ * 基线文件更适合本任务 —— **没有 `-u` 这个逃生口**,改了就是红的,只能改回去。 * * ⚠️ 两份基线,缺一不可: - * · `personal` —— 没有工作区身份。此时面板**只渲染「自有托管部署」那一段**。 + * · `personal` —— 没有工作区身份。此时面板展示登录提示,不渲染团队可见范围或生成链接。 * · `team` —— 有团队工作区身份。此时才渲染**工作区可见范围**与**发布**两段, * 也就是分享功能真正要动的那部分。 * 只存 personal 那一份等于放着三分之二的面板不设防。 @@ -27,7 +27,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { WorkspaceCollabContext } from '@open-design/contracts'; import { buildWorkspacePermissions, @@ -35,8 +35,12 @@ import { } from '@open-design/contracts'; import { FileViewer } from '../../src/components/FileViewer'; +import { TooltipLayer } from '../../src/components/TooltipLayer'; import { CollabProvider, type CollabContextValue } from '../../src/collab/collab-context'; import { resetConsumedActionRequestsForTests } from '../../src/runtime/action-request'; +import { resetCoalescedGet } from '../../src/lib/coalesced-get'; +import { TEAM_PROJECTS_CHANGED_EVENT } from '../../src/collab/useWorkspaceContext'; +import { PROJECT_SHARE_HISTORY_CHANGED_EVENT } from '../../src/components/share/share-publication-events'; import type { ProjectFile } from '../../src/types'; afterEach(() => { @@ -71,7 +75,7 @@ function htmlFile(): ProjectFile { } /** 分享面板挂上之后才发的那几个请求;不喂它们 `canShare` 永远为假,按钮压根不出现。 */ -function stubFetch(published = false) { +function stubFetch(published = false, historicalUrl?: string) { const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input); // Keep the production local social payload fallback instead of returning a malformed payload. @@ -80,7 +84,7 @@ function stubFetch(published = false) { const publication = { url: 'https://open-design.ai/artifact/project-1/stable-slug', slug: 'stable-slug', fileName: 'index.html' }; if (init?.method === 'POST') return new Response(JSON.stringify(publication), { status: 200 }); if (init?.method === 'DELETE') return new Response(JSON.stringify({ ok: true }), { status: 200 }); - return new Response(JSON.stringify({ publication: published ? publication : null }), { status: 200 }); + return new Response(JSON.stringify({ publication: published ? { ...publication, url: historicalUrl ?? publication.url, slug: historicalUrl ? 'historical-slug' : publication.slug } : null }), { status: 200 }); } if (url.includes('/deployments')) return new Response(JSON.stringify({ deployments: [] }), { status: 200 }); if (url.includes('/deploy/config')) return new Response(JSON.stringify({ providerId: 'cloudflare-pages', configured: false }), { status: 200 }); @@ -159,11 +163,12 @@ it('does not issue a stop request from a read-only published HTML share panel', } const { rerender } = render(); fireEvent.click(toolbarAction('Share')); - await screen.findByRole('button', { name: 'Stop sharing' }); + expect(await screen.findByRole('switch', { name: /link access/i })).toHaveAttribute('aria-checked', 'true'); rerender(); - // The host already clears publication state on a permission change. Preserve - // that stricter policy; isolated ShareTab tests cover a retained read-only URL. - await waitFor(() => expect(screen.queryByRole('button', { name: 'Stop sharing' })).toBeNull()); + // Viewer-only permission cannot turn link access off or issue an owner-only + // stop request. S13 signed-out known-URL retention has its own mounted spec. + await waitFor(() => expect(screen.getByRole('switch', { name: /link access/i })).toBeDisabled()); + expect(screen.getByRole('switch', { name: /link access/i })).toHaveAttribute('aria-checked', 'true'); expect(request.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false); }); @@ -264,19 +269,21 @@ describe('Shared share shell header', () => { fireEvent.click(screen.getByRole('button', { name: 'More sharing options' })); const deploy = screen.getByRole('menuitem', { name: /Deploy to Vercel/i }); expect(action.compareDocumentPosition(scope) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(deploy.compareDocumentPosition(scope) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(scope.compareDocumentPosition(deploy) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); it.each([ ['toolbar', false], ['toolbar', true], ['artifact-card', false], ['artifact-card', true], ] as const)('%s published=%s closes and reopens without changing publication', async (origin, published) => { - const fetchMock = stubFetch(published); + const historicUrl = 'https://open-design.ai/artifact/project-1/historical-slug'; + const fetchMock = stubFetch(published, historicUrl); renderViewer(teamContext(), origin === 'artifact-card' ? { shareRequest: { nonce: 701, anchorId: 'header-card' } } : {}); if (origin === 'toolbar') fireEvent.click(toolbarAction('Share')); - if (published) await screen.findByRole('button', { name: /stop sharing/i }); + if (published) await screen.findByRole('switch', { name: /link access/i }); else await screen.findByRole('menuitem', { name: /Generate and copy link/i }); const heading = await screen.findByRole('heading', { name: 'Share', level: 2 }); expect(heading.parentElement?.nextElementSibling).toHaveClass('chrome-unified-panel--share'); + if (published) expect(document.querySelector('.chrome-publish-url')?.textContent).toBe(historicUrl); const close = screen.getByRole('button', { name: 'Close' }); expect(close).toHaveAccessibleName('Close'); expect(close).toBeEnabled(); @@ -285,6 +292,7 @@ describe('Shared share shell header', () => { expect(document.querySelector('.chrome-unified-panel--share')).toBeNull(); fireEvent.click(toolbarAction('Share')); await screen.findByRole('heading', { name: 'Share' }); + if (published) expect(document.querySelector('.chrome-publish-url')?.textContent).toBe(historicUrl); const publishRequests = fetchMock.mock.calls.filter(([input, init]) => String(input).includes('publish-public') && ['POST', 'DELETE'].includes(init?.method ?? 'GET')); expect(publishRequests).toEqual([]); @@ -293,6 +301,8 @@ describe('Shared share shell header', () => { describe('G4 · retired HTML publishing section label', () => { it.each(['toolbar', 'artifact-card'] as const)('%s keeps publishing without the old label', async (origin) => { + const changed = vi.fn(); + window.addEventListener(PROJECT_SHARE_HISTORY_CHANGED_EVENT, changed, { once: true }); const fetchMock = stubFetch(); renderViewer(teamContext(), origin === 'artifact-card' ? { shareRequest: { nonce: 401, anchorId: 'g4-card' } } : {}); @@ -304,7 +314,7 @@ describe('G4 · retired HTML publishing section label', () => { expect(document.querySelectorAll('.chrome-unified-panel--share')).toHaveLength(1); if (origin === 'artifact-card') { expect(document.querySelector('[data-artifact-anchor="g4-card"]')).not.toBeNull(); - expect(document.querySelector('.chrome-access-trigger')).toBeNull(); + expect(document.querySelector('.chrome-access-trigger')).not.toBeNull(); } else { fireEvent.click(document.querySelector('.chrome-access-trigger')!); expect(screen.getAllByRole('option')).toHaveLength(2); @@ -319,25 +329,32 @@ describe('G4 · retired HTML publishing section label', () => { expect(screen.getByRole('option', { name: 'Workspace members' }).querySelector(':scope > svg')).toBeNull(); } fireEvent.click(publish); - await screen.findByRole('button', { name: /stop sharing/i }); + await screen.findByRole('switch', { name: /link access/i }); expect(fetchMock).toHaveBeenCalledWith( '/api/projects/project-1/files/index.html/publish-public', expect.objectContaining({ method: 'POST' }), ); + await waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + expect((changed.mock.calls[0]?.[0] as CustomEvent | undefined)?.detail).toEqual({ projectId: 'project-1' }); }); it.each(['toolbar', 'artifact-card'] as const)('%s hydrates the exact publication URL and keeps copy/stop usable', async (origin) => { + const changed = vi.fn(); + window.addEventListener(PROJECT_SHARE_HISTORY_CHANGED_EVENT, changed, { once: true }); const fetchMock = stubFetch(true); const writeText = vi.fn().mockResolvedValue(undefined); vi.stubGlobal('navigator', { userAgent: navigator.userAgent, clipboard: { writeText } }); renderViewer(teamContext(), origin === 'artifact-card' ? { shareRequest: { nonce: 402, anchorId: 'g4-published-card' } } : {}); if (origin === 'toolbar') fireEvent.click(toolbarAction('Share')); - const stop = await screen.findByRole('button', { name: /stop sharing/i }); + const stop = await screen.findByRole('switch', { name: /link access/i }); const url = 'https://open-design.ai/artifact/project-1/stable-slug'; const panel = document.querySelector('.chrome-unified-panel--share')!; expect(panel.querySelector('.chrome-publish-url')?.textContent).toBe(url); expect(panel.querySelector('.chrome-publish-url')).toHaveAttribute('title', url); + // S4-T: the team visibility control belongs to the unified Share panel regardless of its entry point. + expect(screen.getByText('Visibility in workspace')).toBeVisible(); + expect(panel.querySelector('.chrome-access-trigger')).not.toBeNull(); expect(panel.querySelector('input')).toBeNull(); expect(screen.queryByText('QUICK SHARE · OPENDESIGN')).toBeNull(); expect(stop).toBeEnabled(); @@ -351,9 +368,55 @@ describe('G4 · retired HTML publishing section label', () => { '/api/projects/project-1/files/index.html/publish-public', expect.objectContaining({ method: 'DELETE', body: JSON.stringify({ slug: 'stable-slug' }) }), ); + await waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + expect((changed.mock.calls[0]?.[0] as CustomEvent | undefined)?.detail).toMatchObject({ + projectId: 'project-1', + confirmedStop: { sourceFilePath: 'index.html', accountScope: expect.any(String), generation: expect.any(Number) }, + }); }); }); +it('K3 re-reads remote stop/resume on the still-mounted Owner comment rail, but never invents stop on failed DELETE', async () => { + const fallback = stubFetch(true).getMockImplementation()!; + let stopped = false; + let failStop = false; + const syncReads: boolean[] = []; + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input); + if (url.includes('/comment-sync-state')) { + syncReads.push(stopped); + return new Response(JSON.stringify({ pending: 0, lastError: null, sessionMissing: false, shareStopped: stopped }), { status: 200 }); + } + if (url.includes('/publish-public') && init?.method === 'DELETE') { + if (failStop) return new Response(JSON.stringify({ error: 'temporary failure' }), { status: 503 }); + stopped = true; + } + if (url.includes('/publish-public') && init?.method === 'POST') stopped = false; + return fallback(input, init); + }); + vi.stubGlobal('fetch', fetchMock); + renderViewer(teamContext()); + fireEvent.click(screen.getByTestId('comment-panel-toggle')); + expect(await screen.findByTestId('comment-side-panel')).toBeTruthy(); + await waitFor(() => expect(syncReads).toEqual([false])); + fireEvent.click(toolbarAction('Share')); + fireEvent.click(await screen.findByRole('switch', { name: /link access/i })); + await waitFor(() => expect(syncReads).toContain(true)); + expect(await screen.findByText('The link is disabled.')).toBeVisible(); + expect(screen.getByTestId('comment-side-panel')).toBeTruthy(); + fireEvent.click(await screen.findByRole('menuitem', { name: /Generate and copy link/i })); + await waitFor(() => expect(syncReads.at(-1)).toBe(false)); + await waitFor(() => expect(screen.queryByText('The link is disabled.')).toBeNull()); + failStop = true; + const beforeFailure = syncReads.length; + fireEvent.click(await screen.findByRole('switch', { name: /link access/i })); + await waitFor(() => expect(fetchMock.mock.calls.some(([url, init]) => String(url).includes('/publish-public') && init?.method === 'DELETE' && failStop)).toBe(true)); + await waitFor(() => expect(syncReads.length).toBeGreaterThan(beforeFailure)); + expect(syncReads.at(-1)).toBe(false); + expect(screen.queryByText('The link is disabled.')).toBeNull(); + expect(screen.getByTestId('comment-side-panel')).toBeTruthy(); +}); + describe('S12 · HTML share menu', () => { it.each(['streaming', 'viewerOnly'] as const)('keeps provider actions disabled while %s', async (restriction) => { stubFetch(); @@ -375,20 +438,21 @@ describe('S12 · HTML share menu', () => { expect(screen.queryByRole('combobox', { name: /Provider/i })).toBeNull(); }); - it('keeps artifact-card Share limited to quick publishing', async () => { + it('keeps artifact-card Share focused on publishing while showing team visibility', async () => { stubFetch(); renderViewer(teamContext(), { shareRequest: { nonce: 123, anchorId: 's12-card' } }); await screen.findByRole('menuitem', { name: /Generate and copy link/i }); expect(screen.queryByRole('menuitem', { name: /Deploy to/i })).toBeNull(); expect(document.querySelector('.social-share-grid')).toBeNull(); - expect(document.querySelector('.chrome-access-trigger')).toBeNull(); + expect(screen.getByText('Visibility in workspace')).toBeVisible(); + expect(document.querySelector('.chrome-access-trigger')).not.toBeNull(); }); it('keeps deployment providers but excludes social sharing after publication', async () => { stubFetch(true); renderViewer(teamContext()); fireEvent.click(toolbarAction('Share')); - await screen.findByRole('button', { name: /stop sharing/i }); + await screen.findByRole('switch', { name: /link access/i }); const panel = document.querySelector('.chrome-unified-panel--share')!; expect(panel.querySelector('.chrome-publish-url')?.textContent).toBe('https://open-design.ai/artifact/project-1/stable-slug'); fireEvent.click(screen.getByRole('button', { name: 'More sharing options' })); @@ -397,6 +461,109 @@ describe('S12 · HTML share menu', () => { expect(panel.querySelector('.social-share-grid')).toBeNull(); expect(panel.querySelectorAll('.social-share-button')).toHaveLength(0); }); + it('F14 help hover and leave show then dismiss the real Share-panel explanation without publishing', async () => { + const fetchMock = stubFetch(); + renderViewer(teamContext()); + render(); + fireEvent.click(toolbarAction('Share')); + const help = await screen.findByRole('button', { name: 'Anyone with the link can view it online.' }); + fireEvent.pointerOver(help); + expect(screen.getByRole('tooltip')).toHaveTextContent('Anyone with the link can view it online.'); + fireEvent.pointerOut(help, { relatedTarget: document.body }); + await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull()); + expect(fetchMock.mock.calls.some(([url, init]) => String(url).includes('publish-public') && init?.method === 'POST')).toBe(false); + }); + + it('S1-T2: team scope requires confirmation, commits exact move once, and rolls back a failed return', async () => { + window.localStorage.removeItem('od.projects.moveConfirmSkip'); + const fetchMock = stubFetch(); + const fallback = fetchMock.getMockImplementation(); + let settleTeam!: (value: Response) => void; + let settlePrivate!: (value: Response) => void; + const teamMove = new Promise(resolve => { settleTeam = resolve; }); + const privateMove = new Promise(resolve => { settlePrivate = resolve; }); + const moves: RequestInit[] = []; + let teamShared = false; + fetchMock.mockImplementation(async (input, init) => { + if (String(input).endsWith('/collab/status')) return Response.json(teamShared ? { ownerMemberId: 'member-a' } : {}); + if (String(input).endsWith('/api/workspace/projects/team')) return Response.json({ projects: teamShared ? [{ projectId: 'project-1' }] : [] }); + if (String(input).endsWith('/api/workspaces/workspace-a/projects/project-1/move') && init?.method === 'POST') { + moves.push(init); + return moves.length === 1 ? teamMove : privateMove; + } + return fallback!(input, init); + }); + renderViewer(teamContext()); + fireEvent.click(toolbarAction('Share')); + const privateTrigger = await screen.findByRole('button', { name: 'Only me' }); + const chooseTeam = () => { + fireEvent.click(screen.getByRole('button', { name: 'Only me' })); + const teamOption = screen.getByRole('option', { name: 'Workspace members' }); + fireEvent.click(teamOption); + expect(screen.queryByRole('option', { name: 'Workspace members' })).toBeNull(); + expect(screen.queryByRole('option', { name: 'Only me' })).toBeNull(); + }; + chooseTeam(); + expect(screen.getByRole('alertdialog')).toBeVisible(); + expect(moves).toHaveLength(0); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.queryByRole('alertdialog')).toBeNull(); + expect(privateTrigger).toBeEnabled(); + expect(moves).toHaveLength(0); + chooseTeam(); + fireEvent.click(screen.getByRole('button', { name: 'Confirm move' })); + await waitFor(() => expect(moves).toHaveLength(1)); + expect(JSON.parse(String(moves[0]?.body))).toEqual({ visibility: 'team' }); + expect(moves[0]?.headers).toMatchObject({ 'x-od-workspace-id': 'workspace-a' }); + expect(privateTrigger).toBeDisabled(); + teamShared = true; + await act(async () => settleTeam(Response.json({ project: { id: 'project-1' } }))); + const teamTrigger = await screen.findByRole('button', { name: 'Workspace members' }); + expect(teamTrigger).toBeEnabled(); + expect(screen.getByText('Shared with workspace members')).toBeVisible(); + expect(fetchMock.mock.calls.some(([url, init]) => String(url).includes('publish-public') && init?.method === 'POST')).toBe(false); + + fireEvent.click(teamTrigger); + fireEvent.click(screen.getByRole('option', { name: 'Only me' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm move' })); + await waitFor(() => expect(moves).toHaveLength(2)); + expect(JSON.parse(String(moves[1]?.body))).toEqual({ visibility: 'personal' }); + await act(async () => settlePrivate(Response.json({ error: { message: 'temporarily unavailable' } }, { status: 503 }))); + expect(screen.getByRole('button', { name: 'Workspace members' })).toBeEnabled(); + expect(screen.getByText('Could not move back to private')).toBeVisible(); + }); + + it('S1-T2: a late first team revalidation cannot undo the newest event scope', async () => { + resetCoalescedGet(); + const fetchMock = stubFetch(); + const fallback = fetchMock.getMockImplementation(); + let releaseFirst!: (response: Response) => void; + const staleStatus = new Promise(resolve => { releaseFirst = resolve; }); + let revalidating = false; + let eventReads = 0; + fetchMock.mockImplementation(async (input, init) => { + const url = String(input); + if (url.endsWith('/collab/status')) { + if (!revalidating) return Response.json({}); + eventReads++; + return eventReads === 1 ? staleStatus : Response.json({ ownerMemberId: 'member-a' }); + } + if (url.endsWith('/api/workspace/projects/team')) return Response.json({ projects: [] }); + return fallback!(input, init); + }); + renderViewer(teamContext()); + fireEvent.click(toolbarAction('Share')); + await screen.findByRole('button', { name: 'Only me' }); + revalidating = true; + act(() => { window.dispatchEvent(new Event(TEAM_PROJECTS_CHANGED_EVENT)); }); + await waitFor(() => expect(eventReads).toBe(1)); + act(() => { window.dispatchEvent(new Event(TEAM_PROJECTS_CHANGED_EVENT)); }); + await screen.findByRole('button', { name: 'Workspace members' }); + expect(eventReads).toBe(2); + await act(async () => { releaseFirst(Response.json({})); }); + expect(screen.getByRole('button', { name: 'Workspace members' })).toBeVisible(); + resetCoalescedGet(); + }); }); describe('Z11a · 基线守不住、但必须守住的几条', () => { diff --git a/apps/web/tests/components/FileViewer.share-update.test.tsx b/apps/web/tests/components/FileViewer.share-update.test.tsx new file mode 100644 index 0000000000..fbcd6f1958 --- /dev/null +++ b/apps/web/tests/components/FileViewer.share-update.test.tsx @@ -0,0 +1,376 @@ +// @vitest-environment jsdom +// Owner S5/S6: mount the real Share entry and exercise the daemon GET/POST wire. +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { ComponentProps } from 'react'; +import { buildWorkspacePermissions, buildWorkspaceSeatSummary, type WorkspaceCollabContext } from '@open-design/contracts'; +import { FileViewer } from '../../src/components/FileViewer'; +import { CollabProvider, type CollabContextValue } from '../../src/collab/collab-context'; + +const publication = { url: 'https://example.invalid/artifact/proj/stable', slug: 'stable', fileName: 'index.html' }; +const context: WorkspaceCollabContext = { + workspaceId: 'ws', workspaceType: 'team', teamId: 'ws', workspaceMemberId: 'owner', + role: 'owner', memberStatus: 'active', lifecycleState: 'active', billingState: 'active', + planId: null, providerMode: 'platform_credits', seatSummary: buildWorkspaceSeatSummary({ seatLimit: 3, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }), +}; +const collab: CollabContextValue = { + workspaceContext: context, workspaceContextLoading: false, enabled: false, member: null, + present: [], publishedVersion: null, syncState: null, viewerOnly: false, writerAuthority: 'allowed', + isOwner: true, isEffectiveOwner: true, isSharedNonOwner: false, ownerDisplayName: null, + ownerRole: null, downloadPending: false, reportChange: () => {}, requestPublish: () => {}, + refreshPresence: () => {}, checkStatusNow: () => {}, +}; +const props: ComponentProps = { + projectKind: 'prototype', projectId: 'proj', file: { + name: 'index.html', path: 'index.html', type: 'file', size: 123, mtime: 1, + kind: 'html', mime: 'text/html', artifactManifest: { + version: 1, kind: 'html', title: 'Page', entry: 'index.html', renderer: 'html', exports: ['html'], + }, + }, liveHtml: 'first version', +}; +let copy: ReturnType; +beforeEach(() => { + copy = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: copy } }); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.useRealTimers(); }); + +function publishedRoute(options: { freshness?: 'outdated' | 'current' | 'unknown'; failures?: number; failureStage?: 'push' | 'snapshot' | 'manual'; historyStatus?: 'active' | 'stopped'; fileStatus?: 'active' | 'stopped'; amrAuthenticated?: boolean; noPublication?: boolean } = {}) { + let freshness = options.freshness ?? 'outdated'; + let fileStatus = options.fileStatus ?? 'active'; + let failures = options.failures ?? 0; + let readFails = false; + let deferReads = false; + let resolveDeferredRead: ((response: Response) => void) | null = null; + const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/integrations/vela/status')) return new Response(JSON.stringify({ loggedIn: Boolean(options.amrAuthenticated), loginInFlight: false, profile: 'prod', user: options.amrAuthenticated ? { id: 'owner' } : null, configPath: '/x' }), { status: 200 }); + if (url.includes('/share-state')) return new Response(JSON.stringify({ projectId: 'proj', hasEverShared: !options.noPublication, bindingExists: !options.noPublication, publications: options.noPublication ? [] : [{ sourceFilePath: 'index.html', slug: 'stable', status: options.historyStatus ?? 'active' }] }), { status: 200 }); + if (url.includes('publish-public')) { + if (init?.method === 'POST') { + if (failures-- > 0) return new Response(JSON.stringify(options.failureStage === 'manual' + ? { error: { code: 'PUBLIC_FILE_MANUAL_REVOKE_REQUIRED', message: 'Publication metadata could not be saved', data: { projectId: 'proj', ...publication } } } + : { error: 'PUBLIC_FILE_PUBLISH_UNAVAILABLE', failure: { stage: options.failureStage ?? 'push', reason: 'network' } }), { status: 502 }); + freshness = 'current'; + return new Response(JSON.stringify(publication), { status: 200 }); + } + if (readFails) return new Response(JSON.stringify({ error: 'SHARE_STATE_UNAVAILABLE' }), { status: 503 }); + if (options.noPublication) return new Response(JSON.stringify({ publication: null, status: 'none', freshness: 'unknown' }), { status: 200 }); + if (deferReads) return await new Promise(resolve => { resolveDeferredRead = resolve; }); + return new Response(JSON.stringify({ publication, status: fileStatus, freshness }), { status: 200 }); + } + if (url.includes('/social-share')) return new Response('{}', { status: 503 }); + return new Response(JSON.stringify({ deployments: [] }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetch); + return { + fetch, + setReadFails: (next: boolean) => { readFails = next; }, + setFreshness: (next: typeof freshness) => { freshness = next; }, + setFileStatus: (next: typeof fileStatus) => { fileStatus = next; }, + setReadDeferred: (next: boolean) => { deferReads = next; }, + resolveDeferredRead: () => resolveDeferredRead?.(new Response(JSON.stringify({ publication, status: 'active', freshness }), { status: 200 })), + }; +} +function posts(fetch: ReturnType) { + return fetch.mock.calls.filter(([url, init]) => String(url).includes('publish-public') && init?.method === 'POST'); +} +async function openShare() { + const view = render(); + fireEvent.click(await screen.findByRole('button', { name: /^share$/i })); + await screen.findByText(publication.url); + return view; +} + +it('S0 login success returns to the first-share panel without automatically generating a link', async () => { + const route = publishedRoute({ noPublication: true, amrAuthenticated: true }); + const view = render(); + fireEvent.click(await screen.findByRole('button', { name: /^share$/i })); + expect(screen.getByText('A share link needs a signed-in workspace. Sign in to OpenDesign Cloud, or use a deploy option to share this file.')).toBeVisible(); + await act(async () => fireEvent.click(screen.getByTestId('share-cloud-signin'))); + view.rerender(); + await waitFor(() => expect(screen.getByRole('menuitem', { name: /generate and copy link/i })).toBeEnabled()); + expect(posts(route.fetch)).toHaveLength(0); +}); + +it('S13 witnesses only an authenticated active share GET and revokes a stopped result', async () => { + const route = publishedRoute({ amrAuthenticated: true }); + const onObservedPublicShareLink = vi.fn(); + const view = render( + + ); + fireEvent.click(await screen.findByRole('button', { name: /^share$/i })); + await waitFor(() => expect(onObservedPublicShareLink).toHaveBeenCalledWith({ + status: 'active', projectId: 'proj', filePath: 'index.html', slug: 'stable', + url: publication.url, workspaceId: 'ws', workspaceMemberId: 'owner', + authorizationScopeKey: expect.stringMatching(/^workspace:/), freshness: 'outdated', + })); + const activeCalls = onObservedPublicShareLink.mock.calls.length; + view.rerender( + + ); + expect(onObservedPublicShareLink.mock.calls.length).toBe(activeCalls); + route.setFileStatus('stopped'); + view.rerender( + + ); + await waitFor(() => expect(onObservedPublicShareLink).toHaveBeenLastCalledWith(null)); + expect(posts(route.fetch)).toHaveLength(0); +}); + +it('S13 explicit login-to-update waits for a fresh same-member active/outdated GET and publishes once', async () => { + const route = publishedRoute({ amrAuthenticated: true }); + const onObservedPublicShareLink = vi.fn(); + const first = render( + + ); + await waitFor(() => expect(onObservedPublicShareLink).toHaveBeenCalledWith(expect.objectContaining({ + status: 'active', slug: publication.slug, freshness: 'outdated', + }))); + const link = onObservedPublicShareLink.mock.calls.find(([share]) => share?.status === 'active')?.[0]; + expect(link).toBeTruthy(); + expect(posts(route.fetch)).toHaveLength(0); // Passive login alone never updates. + first.unmount(); + route.setReadDeferred(true); + const readsBefore = route.fetch.mock.calls.filter(([url, init]) => String(url).includes('publish-public') && init?.method !== 'POST').length; + const onLoginUpdateRequestHandled = vi.fn(); + render(); + await waitFor(() => expect(route.fetch.mock.calls.filter(([url, init]) => + String(url).includes('publish-public') && init?.method !== 'POST').length).toBeGreaterThan(readsBefore)); + expect(posts(route.fetch)).toHaveLength(0); + expect(onLoginUpdateRequestHandled).not.toHaveBeenCalled(); + route.setReadDeferred(false); + await act(async () => { route.resolveDeferredRead(); }); + await waitFor(() => expect(posts(route.fetch)).toHaveLength(1)); + expect(onLoginUpdateRequestHandled).toHaveBeenCalledExactlyOnceWith(1); + expect(copy).not.toHaveBeenCalled(); +}); + +it.each([ + ['stopped', 'stopped', 'outdated'], + ['already current', 'active', 'current'], +] as const)('S13 post-login %s GET consumes intent without publishing', async (_label, nextStatus, nextFreshness) => { + const route = publishedRoute({ amrAuthenticated: true }); + const witness = vi.fn(); + const first = render( + + ); + await waitFor(() => expect(witness).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' }))); + const link = witness.mock.calls.find(([share]) => share?.status === 'active')?.[0]; + first.unmount(); + route.setFileStatus(nextStatus); + route.setFreshness(nextFreshness); + const onLoginUpdateRequestHandled = vi.fn(); + render(); + await waitFor(() => expect(onLoginUpdateRequestHandled).toHaveBeenCalledExactlyOnceWith(9)); + expect(posts(route.fetch)).toHaveLength(0); +}); + +it('S13 post-login different workspace member cannot consume a prior scope into an update', async () => { + const route = publishedRoute({ amrAuthenticated: true }); + const witness = vi.fn(); + const first = render(); + await waitFor(() => expect(witness).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' }))); + const link = witness.mock.calls.find(([share]) => share?.status === 'active')?.[0]; + first.unmount(); + const onLoginUpdateRequestHandled = vi.fn(); + render( + + ); + await waitFor(() => expect(onLoginUpdateRequestHandled).toHaveBeenCalledExactlyOnceWith(10)); + expect(posts(route.fetch)).toHaveLength(0); +}); + +it('S13 preserves the known active URL for copy after sign-out without sending a publish or stop mutation', async () => { + const route = publishedRoute(); + const view = await openShare(); + view.rerender(); + expect(screen.getByText(publication.url)).toBeVisible(); + await act(async () => fireEvent.click(screen.getByRole('button', { name: /copy share link/i }))); + expect(copy).toHaveBeenCalledWith(publication.url); + expect(posts(route.fetch)).toHaveLength(0); + expect(route.fetch.mock.calls.filter(([url, init]) => String(url).includes('publish-public') && init?.method === 'DELETE')).toHaveLength(0); +}); + +it('S13 explicit login-after-update intent resumes the same outdated public file once authority returns, never on passive login', async () => { + const route = publishedRoute({ amrAuthenticated: true }); + const view = await openShare(); + view.rerender(); + expect(screen.getByText(publication.url)).toBeVisible(); + expect(posts(route.fetch)).toHaveLength(0); + await act(async () => fireEvent.click(screen.getByTestId('share-cloud-signin'))); + view.rerender(); + await waitFor(() => expect(posts(route.fetch)).toHaveLength(1)); + expect(copy).not.toHaveBeenCalled(); +}); + +it('S13 does not update until a fresh authenticated share-state read confirms the same outdated alias', async () => { + const route = publishedRoute({ amrAuthenticated: true }); + const view = await openShare(); + view.rerender(); + await act(async () => fireEvent.click(screen.getByTestId('share-cloud-signin'))); + route.setReadDeferred(true); + view.rerender(); + await waitFor(() => expect(route.fetch.mock.calls.filter(([url, init]) => String(url).includes('publish-public') && !init?.method).length).toBeGreaterThan(1)); + expect(posts(route.fetch)).toHaveLength(0); + await act(async () => route.resolveDeferredRead()); + await waitFor(() => expect(posts(route.fetch)).toHaveLength(1)); +}); + +it('S13 drops an explicit update intent on account switch and never publishes under the new member', async () => { + const route = publishedRoute({ amrAuthenticated: true }); + const view = await openShare(); + view.rerender(); + await act(async () => fireEvent.click(screen.getByTestId('share-cloud-signin'))); + const nextContext = { ...context, workspaceId: 'ws-other', teamId: 'ws-other', workspaceMemberId: 'member-other' }; + view.rerender(); + await waitFor(() => expect(route.fetch.mock.calls.filter(([url, init]) => String(url).includes('publish-public') && !init?.method).length).toBeGreaterThan(1)); + expect(posts(route.fetch)).toHaveLength(0); +}); + +it('S13 discards the deferred update when the authenticated read says the link was stopped', async () => { + const route = publishedRoute({ amrAuthenticated: true }); + const view = await openShare(); + view.rerender(); + await act(async () => fireEvent.click(screen.getByTestId('share-cloud-signin'))); + route.setFileStatus('stopped'); + view.rerender(); + await waitFor(() => expect(route.fetch.mock.calls.filter(([url, init]) => String(url).includes('publish-public') && !init?.method).length).toBeGreaterThan(1)); + expect(posts(route.fetch)).toHaveLength(0); +}); + +it('S13 discards the deferred update if authenticated share-state is already current', async () => { + const route = publishedRoute({ amrAuthenticated: true }); + const view = await openShare(); + view.rerender(); + await act(async () => fireEvent.click(screen.getByTestId('share-cloud-signin'))); + route.setFreshness('current'); + view.rerender(); + await waitFor(() => expect(route.fetch.mock.calls.filter(([url, init]) => String(url).includes('publish-public') && !init?.method).length).toBeGreaterThan(1)); + expect(posts(route.fetch)).toHaveLength(0); +}); + +it('account switch never exposes or copies the previous account link while fresh status is pending', async () => { + const route = publishedRoute(); + const view = await openShare(); + expect(screen.getByText(publication.url)).toBeTruthy(); + + route.setReadDeferred(true); + const nextContext = { ...context, workspaceId: 'ws-other', teamId: 'ws-other', workspaceMemberId: 'member-other' }; + view.rerender(); + await waitFor(() => expect(route.fetch.mock.calls.filter(([url, init]) => String(url).includes('publish-public') && !init?.method).length).toBeGreaterThan(1)); + + expect(screen.queryByText(publication.url)).toBeNull(); + expect(screen.queryByRole('button', { name: /copy share link/i })).toBeNull(); + expect(copy).not.toHaveBeenCalled(); + route.resolveDeferredRead(); +}); + +it('S5 keeps the old link copyable and updating never happens as a side effect of copy', async () => { + const { fetch } = publishedRoute(); + await openShare(); + expect(screen.getByText(/new changes|updated content/i)).toBeTruthy(); + expect(screen.getByRole('button', { name: /update link/i })).toBeEnabled(); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /copy share link/i })); }); + expect(copy).toHaveBeenCalledWith(publication.url); + expect(posts(fetch)).toHaveLength(0); +}); + +it('S6-OK updates the stable alias, shows global transient success, and never automatically copies', async () => { + const { fetch } = publishedRoute(); + await openShare(); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /update link/i })); }); + await waitFor(() => expect(posts(fetch)).toHaveLength(1)); + expect(screen.getByText(publication.url)).toBeTruthy(); + expect((await screen.findByText('Link updated')).closest('[role="status"]')).toBeTruthy(); + expect(copy).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByRole('button', { name: /update link/i })).toBeNull()); + await waitFor(() => expect(fetch.mock.calls.filter(([url, init]) => String(url).includes('publish-public') && !init?.method).length).toBeGreaterThanOrEqual(3)); +}); + +it('S6-ERR preserves old link after failed update, never retries automatically, then explicit toast retry succeeds', async () => { + const { fetch } = publishedRoute({ failures: 1 }); + await openShare(); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /update link/i })); }); + await waitFor(() => expect(posts(fetch)).toHaveLength(1)); + expect(screen.getByText(publication.url)).toBeTruthy(); + expect(screen.getByRole('button', { name: /copy share link/i })).toBeEnabled(); + expect((await screen.findByText(/Update failed/i)).closest('[role="alert"]')).toBeTruthy(); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); + expect(posts(fetch)).toHaveLength(1); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /^retry$/i })); }); + await waitFor(() => expect(posts(fetch)).toHaveLength(2)); + expect(screen.getByText(publication.url)).toBeTruthy(); + expect(copy).not.toHaveBeenCalled(); +}); + +it.each(['snapshot', 'manual'] as const)('S6-ERR %s remote outcome is uncertain; never claim old bytes or offer blind retry', async (failureStage) => { + const { fetch } = publishedRoute({ failures: 1, failureStage }); + await openShare(); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /update link/i })); }); + await waitFor(() => expect(posts(fetch)).toHaveLength(1)); + expect(screen.getByText(publication.url)).toBeTruthy(); + expect((await screen.findByText(/Update outcome unknown/i)).closest('[role="alert"]')).toBeTruthy(); + expect(screen.queryByText(/previous version is still available/i)).toBeNull(); + expect(screen.queryByRole('button', { name: /^retry$/i })).toBeNull(); + expect(copy).not.toHaveBeenCalled(); +}); + +it('S5 responds to filesRefreshKey with unchanged mtime while the panel stays open', async () => { + const { fetch, setFreshness } = publishedRoute({ freshness: 'current' }); + const view = await openShare(); + expect(screen.queryByRole('button', { name: /update link/i })).toBeNull(); + setFreshness('outdated'); + const before = fetch.mock.calls.filter(([url, init]) => String(url).includes('publish-public') && !init?.method).length; + view.rerender(); + await waitFor(() => expect(fetch.mock.calls.filter(([url, init]) => String(url).includes('publish-public') && !init?.method).length).toBeGreaterThan(before)); + await waitFor(() => expect(screen.getByRole('button', { name: /update link/i })).toBeEnabled()); + expect(posts(fetch)).toHaveLength(0); +}); + +it('S5 trusts one coherent per-file status/freshness result over a stale project history', async () => { + const { fetch } = publishedRoute({ historyStatus: 'stopped', fileStatus: 'active' }); + await openShare(); + await waitFor(() => expect(screen.getByRole('button', { name: /update link/i })).toBeEnabled()); + await act(async () => fireEvent.click(screen.getByRole('button', { name: /update link/i }))); + expect(posts(fetch)).toHaveLength(1); +}); + +it('S5 never exposes Update or a stopped link when per-file GET says stopped even if history says active', async () => { + const { fetch } = publishedRoute({ historyStatus: 'active', fileStatus: 'stopped' }); + render(); + fireEvent.click(await screen.findByRole('button', { name: /^share$/i })); + await screen.findByRole('switch', { name: /link access/i }); + expect(screen.queryByText(publication.url)).toBeNull(); + expect(screen.queryByRole('button', { name: /update link/i })).toBeNull(); + expect(posts(fetch)).toHaveLength(0); +}); + +it('a failed fingerprint read on content change retains the previously known URL but withdraws outdated certainty', async () => { + const { fetch, setReadFails } = publishedRoute(); + const view = await openShare(); + expect(screen.getByRole('button', { name: /update link/i })).toBeEnabled(); + setReadFails(true); + view.rerender(); + await waitFor(() => expect(fetch.mock.calls.filter(([url, init]) => String(url).includes('publish-public') && !init?.method).length).toBeGreaterThanOrEqual(3)); + await waitFor(() => expect(screen.queryByRole('button', { name: /update link/i })).toBeNull()); + expect(screen.getByText(publication.url)).toBeTruthy(); + expect(screen.getByRole('button', { name: /copy share link/i })).toBeEnabled(); + expect(posts(fetch)).toHaveLength(0); +}); + +it('unknown freshness keeps the existing copyable link but does not falsely label it outdated', async () => { + const { fetch } = publishedRoute({ freshness: 'unknown' }); + await openShare(); + expect(screen.getByRole('button', { name: /copy share link/i })).toBeEnabled(); + expect(screen.queryByRole('button', { name: /update link/i })).toBeNull(); + expect(posts(fetch)).toHaveLength(0); +}); diff --git a/apps/web/tests/components/FileViewer.test.tsx b/apps/web/tests/components/FileViewer.test.tsx index 6680fb47dc..e555e85af8 100644 --- a/apps/web/tests/components/FileViewer.test.tsx +++ b/apps/web/tests/components/FileViewer.test.tsx @@ -5297,6 +5297,69 @@ describe('FileViewer SVG artifacts', () => { ); }); + it('keeps React-component sharing disabled across module reclassification', async () => { + const makeFile = (name: string, mtime = 1) => baseFile({ + name, path: name, mtime, mime: 'text/jsx', kind: 'code', + artifactManifest: { version: 1, kind: 'react-component', title: name, entry: name, renderer: 'react-component', exports: ['jsx'] }, + }); + let activeProjectId = 'project-1'; + let referencingHtml = false; + let resolveHtml!: (response: Response) => void; + const pendingHtml = new Promise((resolve) => { resolveHtml = resolve; }); + vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input); + if (url.includes(`/api/projects/${activeProjectId}/files`)) { + return new Response(JSON.stringify({ files: referencingHtml + ? [{ name: 'A.jsx', path: 'A.jsx' }, { name: 'backups.html', path: 'backups.html' }] + : [{ name: 'A.jsx', path: 'A.jsx' }] })); + } + if (url.includes(`/api/projects/${activeProjectId}/raw/backups.html`)) return pendingHtml; + if (url.endsWith('/raw/A.jsx') || url.endsWith('/raw/B.jsx')) return new Response('export default function Icon() { return null; }'); + return new Response('', { status: 404 }); + })); + + const { rerender } = render(); + const share = await screen.findByRole('button', { name: /share/i }); + await waitFor(() => expect(share).toBeDisabled()); + expect(share.getAttribute('title')).toBeTruthy(); + + referencingHtml = true; + activeProjectId = 'project-2'; + rerender(); + // Let the component's source refresh finish, but keep sibling HTML pending. + // This exposes the old toolbar state during module reclassification. + await waitFor(() => expect(vi.mocked(fetch).mock.calls.filter(([input]) => String(input).endsWith('/raw/A.jsx')).length).toBeGreaterThan(1)); + await waitFor(() => expect(vi.mocked(fetch).mock.calls.some(([input]) => String(input).includes('/raw/backups.html'))).toBe(true)); + expect(screen.queryByRole('menu')).toBeNull(); + resolveHtml(new Response('')); + await waitFor(() => expect(screen.getByRole('button', { name: /share/i })).toBeDisabled()); + expect(screen.queryByRole('menu')).toBeNull(); + expect(vi.mocked(fetch).mock.calls.some(([input]) => String(input).includes('publish-public'))).toBe(false); + }); + + it.each(['Widget.jsx', 'Widget.tsx'])('keeps standalone %s HTML-only for sharing', async (name) => { + const file = baseFile({ + name, path: name, mime: 'text/plain', kind: 'code', + artifactManifest: { version: 1, kind: 'react-component', title: 'Widget', entry: name, renderer: 'react-component', exports: ['jsx'] }, + }); + vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input); + if (url === '/api/projects/project-1/files') { + return Response.json({ files: [{ name, path: name }] }); + } + if (url === `/api/projects/project-1/raw/${name}`) return new Response('export default function Widget() { return null; }'); + return new Response('', { status: 404 }); + })); + + render(); + const share = await screen.findByRole('button', { name: /share/i }); + await waitFor(() => expect(share).toBeDisabled()); + expect(share).toHaveAttribute('title', 'Only self-contained HTML files can be shared. Export this component as HTML first.'); + fireEvent.click(share); + expect(screen.queryByRole('menu')).toBeNull(); + expect(vi.mocked(fetch).mock.calls.some(([input]) => String(input).includes('publish-public'))).toBe(false); + }); + it('points a .jsx module loaded by a sibling HTML to that entry, not the React error (issue #2744)', async () => { const file = baseFile({ name: 'icons.jsx', @@ -5312,6 +5375,8 @@ describe('FileViewer SVG artifacts', () => { exports: ['jsx'], }, }); + let resolveHtmlSource!: (response: Response) => void; + const pendingHtmlSource = new Promise((resolve) => { resolveHtmlSource = resolve; }); vi.stubGlobal( 'fetch', vi.fn(async (input: string | URL | Request) => { @@ -5327,7 +5392,7 @@ describe('FileViewer SVG artifacts', () => { ); } if (url === '/api/projects/project-1/raw/backups.html') { - return new Response(''); + return pendingHtmlSource; } if (url === '/api/projects/project-1/raw/icons.jsx') { return new Response('window.I = { star: null };'); @@ -5346,11 +5411,27 @@ describe('FileViewer SVG artifacts', () => { />, ); + // Module status is unknown until the sibling HTML read finishes. Fail + // closed during that window, then resolve the fixture's real module link. + const shareButton = await screen.findByRole('button', { name: /share/i }); + expect(shareButton).toBeDisabled(); + expect(shareButton.getAttribute('title')).toBeTruthy(); + resolveHtmlSource(new Response('')); + // The module points at its HTML entry instead of rendering the React // runtime (which would throw "No React component export found"). await screen.findByRole('button', { name: /backups\.html/ }); expect(screen.queryByTestId('react-component-preview-frame')).toBeNull(); + // Sharing a referenced module is unsupported: wait for classification, then + // verify the toolbar gives a reason and no public publish request occurred. + await waitFor(() => expect(shareButton).toBeDisabled()); + expect(shareButton.getAttribute('title')).toBeTruthy(); + fireEvent.click(shareButton); + expect(screen.queryByRole('menu')).toBeNull(); + const fetchMock = vi.mocked(fetch); + expect(fetchMock.mock.calls.some(([input]) => String(input).includes('publish-public'))).toBe(false); + // The toolbar still offers a way to read the raw code: clicking the Code // tab swaps the pointer for the file's source. Issue #2744 follow-up. fireEvent.click(screen.getByRole('button', { name: /^code$/i })); @@ -6659,7 +6740,7 @@ describe('FileViewer SVG artifacts', () => { expect(screen.getAllByText(/requiring authentication/i).length).toBeGreaterThan(0); fireEvent.click(openSharePage); - expect(openSpy).toHaveBeenCalledWith('https://protected.example', '_blank', 'noopener'); + expect(openSpy).toHaveBeenCalledWith('https://protected.example/', '_blank', 'noopener,noreferrer'); }); it('shows one copy link when only one deployment provider has a URL', async () => { @@ -6944,6 +7025,7 @@ describe('FileViewer SVG artifacts', () => { }); it('exposes Stop sharing when publication persistence and compensation both fail', async () => { + const dispatch = vi.spyOn(window, 'dispatchEvent'); const context = teamWorkspaceContext(); const publicUrl = 'https://hub.example.test/api/v1/public/snapshots/manual-revoke-slug/files/index.html'; @@ -6998,11 +7080,20 @@ describe('FileViewer SVG artifacts', () => { fireEvent.click(await screen.findByRole('menuitem', { name: /Generate and copy link/i })); expect(await screen.findByText(publicUrl)).toBeTruthy(); - const stopSharing = screen.getByRole('button', { name: /Stop sharing/i }); - fireEvent.click(stopSharing); + const stopSwitch = screen.getByRole('switch', { name: /link access/i }); + expect(stopSwitch).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(stopSwitch); await waitFor(() => expect(unpublishBodies).toEqual([{ slug: 'manual-revoke-slug' }])); expect(await screen.findByRole('menuitem', { name: /Generate and copy link/i })).toBeTruthy(); + const confirmedStops = dispatch.mock.calls.map(([event]) => event) + .filter((event) => event.type === 'od:project-share-history-changed') + .map((event) => (event as CustomEvent).detail) + .filter((detail) => detail?.confirmedStop); + expect(confirmedStops).toEqual([{ + projectId: 'project-1', + confirmedStop: { sourceFilePath: 'index.html', accountScope: expect.any(String), generation: expect.any(Number) }, + }]); }); // Reading the help must never publish. The publish row's trailing "?" carries @@ -7087,35 +7178,10 @@ describe('FileViewer SVG artifacts', () => { } publishHelpCase(publicPublishFile, 'HtmlViewer'); - publishHelpCase( - () => - baseFile({ - name: 'Widget.tsx', - path: 'Widget.tsx', - mime: 'text/plain', - kind: 'code', - artifactManifest: { - version: 1, - kind: 'react-component', - title: 'Widget', - entry: 'Widget.tsx', - renderer: 'react-component', - exports: ['jsx'], - }, - }), - 'ReactComponentViewer', - ); - - it.each(['html', 'react-component'] as const)('supports scope keyboard navigation in the %s viewer without writing', async (kind) => { + it('supports scope keyboard navigation in the HTML viewer without writing', async () => { const context = teamWorkspaceContext(); stubFetchWithWorkspaceContext(context); - const file = kind === 'html' ? publicPublishFile() : baseFile({ - name: 'Widget.tsx', path: 'Widget.tsx', mime: 'text/plain', kind: 'code', - artifactManifest: { - version: 1, kind: 'react-component', title: 'Widget', entry: 'Widget.tsx', - renderer: 'react-component', exports: ['jsx'], - }, - }); + const file = publicPublishFile(); renderWithProjectWorkspace( , @@ -11086,15 +11152,47 @@ describe('FileViewer tweaks toolbar', () => { }); - it('does not light the dot for a trusted member self comment or timestamp boundary', async () => { + it('does not light the dot for a newer trusted member self comment or another member at the read boundary', async () => { const own: PreviewComment = { - id: 'own', projectId: 'project-1', conversationId: 'conversation-1', filePath: 'preview.html', elementId: 'own', selector: '[data-od-id="own"]', label: 'own', text: '', htmlHint: '', position: { x: 0, y: 0, width: 1, height: 1 }, note: 'own', status: 'open', createdAt: 101, updatedAt: 101, authorMemberId: 'wm-1', + id: 'own', projectId: 'project-1', conversationId: 'conversation-1', filePath: 'preview.html', elementId: 'own', selector: '[data-od-id="own"]', label: 'own', text: '', htmlHint: '', position: { x: 0, y: 0, width: 1, height: 1 }, note: 'own', status: 'open', createdAt: 102, updatedAt: 102, authorMemberId: 'wm-1', }; - vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => String(input).endsWith('/comments/read') + const readFetch = vi.fn(async (input: RequestInfo | URL) => String(input).endsWith('/comments/read') ? new Response(JSON.stringify({ projectId: 'project-1', lastReadAt: 101 }), { headers: { 'Content-Type': 'application/json' } }) - : new Response('{}', { headers: { 'Content-Type': 'application/json' } }))); - renderWithProjectWorkspace(, teamWorkspaceContext()); - await waitFor(() => expect(screen.queryByTestId('comment-unread-dot')).toBeNull()); + : new Response('{}', { headers: { 'Content-Type': 'application/json' } })); + vi.stubGlobal('fetch', readFetch); + renderWithProjectWorkspace(, teamWorkspaceContext()); + await waitFor(() => expect(readFetch.mock.calls.some(([input]) => String(input).endsWith('/comments/read'))).toBe(true)); + await act(async () => { await Promise.resolve(); }); + expect(screen.queryByTestId('comment-unread-dot')).toBeNull(); + }); + + it('keeps the unread dot after a failed read acknowledgment until a later server-confirmed retry', async () => { + const firstRead = deferredResponse(); + let readWrites = 0; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (!String(input).endsWith('/comments/read')) return new Response('{}'); + if (init?.method === 'PUT') { + readWrites++; + return readWrites === 1 ? firstRead.promise : new Response(JSON.stringify({ projectId: 'project-1', lastReadAt: 103 }), { headers: { 'Content-Type': 'application/json' } }); + } + return new Response(JSON.stringify({ projectId: 'project-1', lastReadAt: 100 }), { headers: { 'Content-Type': 'application/json' } }); + })); + const external: PreviewComment = { + id: 'external', projectId: 'project-1', conversationId: 'conversation-1', filePath: 'preview.html', elementId: 'external', selector: '[data-od-id="external"]', label: 'external', text: '', htmlHint: '', position: { x: 0, y: 0, width: 1, height: 1 }, note: 'external', status: 'open', createdAt: 103, updatedAt: 103, + }; + render(); + expect(await screen.findByTestId('comment-unread-dot')).toBeVisible(); + fireEvent.click(screen.getByTestId('comment-panel-toggle')); + await waitFor(() => expect(readWrites).toBe(1)); + fireEvent.click(screen.getByTestId('comment-panel-toggle')); + expect(screen.getByTestId('comment-unread-dot')).toBeVisible(); + await act(async () => firstRead.resolve(new Response('{}', { status: 500 }))); + expect(screen.getByTestId('comment-unread-dot')).toBeVisible(); + fireEvent.click(screen.getByTestId('comment-panel-toggle')); + await waitFor(() => expect(readWrites).toBe(2)); + await act(async () => { await Promise.resolve(); }); + fireEvent.click(screen.getByTestId('comment-panel-toggle')); + expect(screen.queryByTestId('comment-unread-dot')).toBeNull(); }); it('keeps comments and annotation picker mutually exclusive', () => { @@ -13689,6 +13787,50 @@ describe('FileViewer tweaks toolbar', () => { expect(shellRule('.comment-float-host .comment-side-header')).toContain('min-height: 40px;'); }); + it('folds only overflowing list bodies and keeps each disclosure independent of row reply', () => { + const scroll = vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(function (this: HTMLElement) { + return this.classList.contains('comment-side-body') && this.textContent?.startsWith('long') ? 120 : 24; + }); + const height = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function (this: HTMLElement) { + return this.classList.contains('comment-side-body') ? 57 : 24; + }); + try { + const make = (id: string, note: string, createdAt: number): PreviewComment => ({ + id, projectId: 'project-1', conversationId: 'conversation-1', filePath: 'preview.html', + elementId: id, selector: '[data-od-id="comment"]', label: 'Comment', text: 'Comment', + htmlHint: '

', position: { x: 0, y: 0, width: 100, height: 30 }, + note, status: 'open', createdAt, updatedAt: createdAt, + }); + const onReply = vi.fn(); + render( {}} onToggleSelect={() => {}} onSelectAll={() => {}} + onClearSelection={() => {}} onReply={onReply} onSendSelected={() => {}} sending={false} t={t} + />); + const [first, brief, second] = screen.getAllByTestId('comment-side-item'); + expect(within(brief!).queryByRole('button', { name: 'chat.comments.expandBody' })).toBeNull(); + const firstBody = first!.querySelector('.comment-side-body')!; + expect(firstBody).toHaveClass('is-clamped'); + const expand = within(first!).getByRole('button', { name: 'chat.comments.expandBody' }); + expect(expand).toHaveAttribute('aria-expanded', 'false'); + fireEvent.keyDown(expand, { key: 'Enter' }); + fireEvent.click(expand); + expect(onReply).not.toHaveBeenCalled(); + expect(firstBody).not.toHaveClass('is-clamped'); + expect(within(first!).getByRole('button', { name: 'chat.comments.collapseBody' })).toHaveAttribute('aria-expanded', 'true'); + expect(within(second!).getByRole('button', { name: 'chat.comments.expandBody' })).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(within(first!).getByRole('button', { name: 'chat.comments.collapseBody' })); + expect(firstBody).toHaveClass('is-clamped'); + expect(onReply).not.toHaveBeenCalled(); + const css = readFileSync(join(process.cwd(), 'src/styles/viewer/core.css'), 'utf8'); + expect(css).toContain('-webkit-line-clamp: 3;'); + } finally { + scroll.mockRestore(); + height.mockRestore(); + } + }); + it('reorders saved comments with the drag handle for send sequence', () => { const onReorder = vi.fn(); const comments: PreviewComment[] = [ diff --git a/apps/web/tests/components/FileWorkspace.test.tsx b/apps/web/tests/components/FileWorkspace.test.tsx index 6dc86c9b09..7856bea33e 100644 --- a/apps/web/tests/components/FileWorkspace.test.tsx +++ b/apps/web/tests/components/FileWorkspace.test.tsx @@ -22,6 +22,9 @@ import { settleManualEditExit, } from '../../src/components/FileWorkspace'; import { ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT } from '../../src/components/workspace/tab-launcher'; +import { fileViewerSourceAuthorizationScopeKey } from '../../src/components/FileViewer'; +import { nextShareRequestNonce } from '../../src/components/share-request-nonce'; +import type { ObservedShareUpdateRequest } from '../../src/components/share/observed-public-share-link'; import { I18nProvider } from '../../src/i18n'; import { DesignFilesPanel } from '../../src/components/DesignFilesPanel'; import { @@ -344,6 +347,110 @@ function collabValue(workspaceContext: WorkspaceCollabContext): CollabContextVal }; } +it('keeps an observed share witness through a transient missing active file but clears it on a real file switch', async () => { + const onObservedPublicShareLink = vi.fn(); + const file = workspaceFile('index.html'); + const props: React.ComponentProps = { + projectId: 'project-1', projectKind: 'prototype', files: [file], liveArtifacts: [], + onRefreshFiles: vi.fn(), isDeck: false, + tabsState: { tabs: ['index.html'], active: 'index.html' }, onTabsStateChange: vi.fn(), + onObservedPublicShareLink, + }; + const view = render(); + await waitFor(() => expect(onObservedPublicShareLink).toHaveBeenCalledWith(null)); + onObservedPublicShareLink.mockClear(); + + view.rerender(); + expect(onObservedPublicShareLink).not.toHaveBeenCalled(); + + view.rerender(); + expect(onObservedPublicShareLink).not.toHaveBeenCalled(); + + const otherFile = workspaceFile('other.html'); + view.rerender(); + await waitFor(() => expect(onObservedPublicShareLink).toHaveBeenCalledWith(null)); +}); +it('S14 shared HTML deletion warns about the exact active file link before a cancellable delete', async () => { + const context = teamContext('workspace-a', 'member-a'); + const request = vi.fn(async (url: RequestInfo | URL) => String(url).endsWith('/share-state') + ? Response.json({ projectId: 'project-1', bindingExists: true, hasEverShared: true, publications: [ + { sourceFilePath: 'index.html', slug: 'live', status: 'active' }, + { sourceFilePath: 'old.html', slug: 'old', status: 'stopped' }, + ] }) + : new Response('', { status: 200 })); + const confirmDelete = vi.fn(() => false); + vi.stubGlobal('fetch', request); + vi.stubGlobal('confirm', confirmDelete); + render(); + fireEvent.click(screen.getByTestId('design-file-menu-index.html')); + fireEvent.click(screen.getByTestId('design-file-delete-index.html')); + await waitFor(() => expect(confirmDelete).toHaveBeenCalledWith(expect.stringContaining('1 pages being shared'))); + expect(request).toHaveBeenCalledWith('/api/projects/project-1/share-state', expect.objectContaining({ headers: expect.objectContaining({ 'x-od-workspace-id': 'workspace-a' }) })); + expect(request.mock.calls.some(([url]) => String(url).includes('/files/index.html') && !String(url).endsWith('/preview'))).toBe(false); +}); + +it('S14 batch deletion warns only for active links attached to selected HTML files', async () => { + const request = vi.fn(async (url: RequestInfo | URL) => String(url).endsWith('/share-state') + ? Response.json({ projectId: 'project-1', bindingExists: true, hasEverShared: true, publications: [ + { sourceFilePath: 'index.html', slug: 'live', status: 'active' }, + { sourceFilePath: 'other.html', slug: 'other', status: 'active' }, + { sourceFilePath: 'index.html', slug: 'old', status: 'stopped' }, + ] }) : new Response('', { status: 200 })); + const confirmDelete = vi.fn(() => false); + vi.stubGlobal('fetch', request); + vi.stubGlobal('confirm', confirmDelete); + const { container } = render(); + fireEvent.click(screen.getByTestId('design-file-row-index.html').querySelector('.df-card-check')!); + fireEvent.click(container.querySelector('[data-testid="design-files-batch-delete"]')!); + await waitFor(() => expect(confirmDelete).toHaveBeenCalledWith(expect.stringContaining('1 pages being shared'))); + expect(request.mock.calls.filter(([url]) => String(url).endsWith('/share-state'))).toHaveLength(1); +}); + +it('S14 HTML deletion without workspace identity does not guess that the file has no active links', async () => { + const request = vi.fn(async (_url: RequestInfo | URL) => new Response('', { status: 200 })); + const confirmDelete = vi.fn(() => true); + const alertFailure = vi.fn(); + vi.stubGlobal('fetch', request); + vi.stubGlobal('confirm', confirmDelete); + vi.stubGlobal('alert', alertFailure); + render(); + fireEvent.click(screen.getByTestId('design-file-menu-index.html')); + fireEvent.click(screen.getByTestId('design-file-delete-index.html')); + await waitFor(() => expect(alertFailure).toHaveBeenCalled()); + expect(confirmDelete).not.toHaveBeenCalled(); + expect(request.mock.calls.filter(([url]) => String(url).endsWith('/share-state'))).toHaveLength(0); +}); +it('S14 unavailable share-state blocks HTML deletion rather than silently claiming no active links', async () => { + const request = vi.fn(async (_url: RequestInfo | URL) => new Response('', { status: 503 })); + const confirmDelete = vi.fn(() => true); + const alertFailure = vi.fn(); + vi.stubGlobal('fetch', request); + vi.stubGlobal('confirm', confirmDelete); + vi.stubGlobal('alert', alertFailure); + render(); + fireEvent.click(screen.getByTestId('design-file-menu-index.html')); + fireEvent.click(screen.getByTestId('design-file-delete-index.html')); + await waitFor(() => expect(alertFailure).toHaveBeenCalled()); + expect(confirmDelete).not.toHaveBeenCalled(); + expect(request.mock.calls.filter(([url]) => String(url).endsWith('/share-state'))).toHaveLength(1); +}); + function cssDeclarations(css: string, selector: string): string { const blocks: string[] = []; const rulePattern = /([^{}]+)\{([^}]*)\}/g; @@ -467,12 +574,6 @@ function stubTabRect(tab: HTMLElement, left = 0, width = 100) { })); } -function changeInputValue(input: HTMLInputElement, value: string) { - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; - setter?.call(input, value); - input.dispatchEvent(new Event('input', { bubbles: true })); -} - function renderDesignFilesPanel(overrides: Partial> = {}) { const props: React.ComponentProps = { projectId: 'project-1', @@ -1572,6 +1673,93 @@ describe('FileWorkspace launcher tab creation', () => { expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(2); }); + it.each(['beta', 'design-files', 'initial-design-files', 'initial-route-pending'] as const)( + 'S13 consumes same-file login update intent on %s selection without resurrecting on return to A', + async (destination) => { + const alpha = workspaceFile('alpha.html'); + const beta = workspaceFile('beta.html'); + const workspace = teamContext('workspace-a', 'member-a'); + const authorizationScopeKey = fileViewerSourceAuthorizationScopeKey(false, workspace) ?? ''; + if (!authorizationScopeKey) throw new Error('missing test workspace scope'); + const onLoginUpdateRequestHandled = vi.fn(); + const publishFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes('publish-public')) return new Response(JSON.stringify(init?.method === 'POST' + ? { url: 'https://example.invalid/alpha', slug: 'alpha', fileName: alpha.name } + : { publication: { url: 'https://example.invalid/alpha', slug: 'alpha', fileName: alpha.name }, + status: 'active', freshness: 'outdated' }), { status: 200 }); + if (url.includes('/share-state')) return new Response(JSON.stringify({ + projectId: 'project-1', hasEverShared: true, bindingExists: true, + publications: [{ sourceFilePath: alpha.name, slug: 'alpha', status: 'active' }], + }), { status: 200 }); + if (url.includes('/integrations/vela/status')) return new Response(JSON.stringify({ + loggedIn: true, loginInFlight: false, user: { id: 'account-1' }, + }), { status: 200 }); + return new Response('{}', { status: 200 }); + }); + if (destination === 'initial-design-files' || destination === 'initial-route-pending') { + vi.stubGlobal('fetch', publishFetch); + } + mockedFetchProjectFileText.mockImplementation(async (_projectId, fileName) => ( + `${fileName}` + )); + function Harness() { + const [tabsState, setTabsState] = useState({ + tabs: [alpha.name, beta.name], + active: destination.startsWith('initial-') ? null : alpha.name, + }); + const [openRequest, setOpenRequest] = useState<{ name: string; nonce: number } | null>(null); + const [request, setRequest] = useState({ + nonce: 17, accountId: 'account-1', expiresAt: Date.now() + 60_000, + link: { status: 'active', projectId: 'project-1', filePath: alpha.name, + slug: 'alpha', url: 'https://example.invalid/alpha', workspaceId: workspace.workspaceId, + workspaceMemberId: workspace.workspaceMemberId, authorizationScopeKey, freshness: 'outdated' }, + }); + return <> + + + { + onLoginUpdateRequestHandled(nonce); + setRequest(null); + }} /> + + ; + } + render(); + if (destination === 'initial-route-pending') { + expect(onLoginUpdateRequestHandled).not.toHaveBeenCalled(); + fireEvent.click(screen.getByTestId('open-requested-route-file')); + await waitFor(() => expect(publishFetch.mock.calls.filter(([url, init]) => + String(url).includes('publish-public') && init?.method === 'POST')).toHaveLength(1)); + expect(onLoginUpdateRequestHandled).toHaveBeenCalledExactlyOnceWith(17); + return; + } + if (destination === 'initial-design-files') { + await waitFor(() => expect(onLoginUpdateRequestHandled).toHaveBeenCalledExactlyOnceWith(17)); + fireEvent.click(screen.getByRole('tab', { name: /alpha\.html/i })); + await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalled()); + expect(onLoginUpdateRequestHandled).toHaveBeenCalledTimes(1); + expect(publishFetch.mock.calls.filter(([url, init]) => + String(url).includes('publish-public') && init?.method === 'POST')).toHaveLength(0); + return; + } + await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalled()); + expect(onLoginUpdateRequestHandled).not.toHaveBeenCalled(); + if (destination === 'beta') fireEvent.click(screen.getByRole('tab', { name: /beta\.html/i })); + else fireEvent.click(screen.getByTestId('design-files-tab')); + await waitFor(() => expect(onLoginUpdateRequestHandled).toHaveBeenCalledExactlyOnceWith(17)); + fireEvent.click(screen.getByRole('tab', { name: /alpha\.html/i })); + expect(onLoginUpdateRequestHandled).toHaveBeenCalledTimes(1); + }, + ); + it('evicts the fourth HTML tab without reattaching the three surviving preview frames', async () => { const files = ['alpha.html', 'beta.html', 'gamma.html', 'delta.html'].map(workspaceFile); mockedFetchProjectFileText.mockImplementation(async (_projectId, fileName) => ( @@ -3050,20 +3238,22 @@ describe('FileWorkspace launcher tab creation', () => { url: 'https://dribbble.com/', }, ]; - - render( - ({ tabs: ['cover.html'], active: '__browser__:1', browserTabs }); + return , - ); + tabsState={tabs} + shareRequest={request} + onTabsStateChange={next => { onTabsStateChange(next); setTabs(next); }} + />; + } + render(); await waitFor(() => { expect(onTabsStateChange).toHaveBeenCalledWith({ @@ -3072,6 +3262,37 @@ describe('FileWorkspace launcher tab creation', () => { browserTabs, }); }); + expect(await screen.findByRole('heading', { name: 'Share', level: 2 })).toBeVisible(); + }); + + it('F13: two artifact-card requests at the same clock tick reopen the real Share panel after close', async () => { + const onTabsStateChange = vi.fn(); + let lastNonce = 100; + function Host() { + const [tabs, setTabs] = useState({ tabs: ['cover.html'], active: '__browser__:1', browserTabs: [{ id: '__browser__:1', label: 'Browser 1', title: 'Dribbble', url: 'https://dribbble.com/' }] }); + const [shareRequest, setShareRequest] = useState<{ name: string; nonce: number; anchorId: string } | null>({ name: 'landing.html', nonce: 100, anchorId: 'artifact-card' }); + return <> + + { onTabsStateChange(next); setTabs(next); }} /> + ; + } + render(); + await waitFor(() => expect(onTabsStateChange).toHaveBeenCalledWith(expect.objectContaining({ active: 'landing.html' }))); + expect(await screen.findByRole('heading', { name: 'Share', level: 2 })).toBeVisible(); + expect(lastNonce).toBe(100); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByRole('heading', { name: 'Share', level: 2 })).toBeNull()); + fireEvent.click(screen.getByRole('button', { name: 'Artifact card Share' })); + expect(lastNonce).toBe(101); + expect(await screen.findByRole('heading', { name: 'Share', level: 2 })).toBeVisible(); + expect(onTabsStateChange).toHaveBeenCalledTimes(2); + expect(onTabsStateChange).toHaveBeenLastCalledWith(expect.objectContaining({ active: 'landing.html', browserTabs: [expect.objectContaining({ url: 'https://dribbble.com/' })] })); }); it('opens and activates the target file for a download request', async () => { @@ -3103,6 +3324,67 @@ describe('FileWorkspace launcher tab creation', () => { }); }); + it('S14 design-system project delete requires authoritative active-share warning before backing-project deletion', async () => { + const onDeleteDesignSystemProject = vi.fn(async () => false); + const request = vi.fn(async (url: RequestInfo | URL) => { + if (String(url) === '/api/projects/project-1/share-state') return Response.json({ + projectId: 'project-1', bindingExists: true, hasEverShared: true, + publications: [{ slug: 'shared-kit', sourceFilePath: 'kit.html', status: 'active' }], + }); + return new Response('', { status: 200 }); + }); + vi.stubGlobal('fetch', request); + vi.stubGlobal('confirm', vi.fn(() => true)); + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'More actions' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete Neutral Modern' })); + const dialog = await screen.findByTestId('project-delete-confirm-dialog'); + await waitFor(() => expect(within(dialog).getByText(/1 page.*shared|1 active/i)).toBeTruthy()); + expect(onDeleteDesignSystemProject).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByTestId('project-delete-confirm-accept')); + await waitFor(() => expect(onDeleteDesignSystemProject).toHaveBeenCalledWith('project-1')); + }); + + it.each(['missing workspace identity', 'share-state HTTP failure'])( + 'S14 design-system delete blocks unknown active-share count: %s', async (failure) => { + const onDeleteDesignSystemProject = vi.fn(async () => true); + const request = vi.fn(async (_url: RequestInfo | URL) => new Response('', { status: 503 })); + vi.stubGlobal('fetch', request); + vi.stubGlobal('confirm', vi.fn(() => true)); + const panel = ( + + ); + render(failure === 'missing workspace identity' ? panel + : {panel}); + fireEvent.click(await screen.findByRole('button', { name: 'More actions' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete Neutral Modern' })); + const dialog = await screen.findByTestId('project-delete-confirm-dialog'); + await waitFor(() => expect(within(dialog).getByTestId('project-delete-confirm-accept').hasAttribute('disabled')).toBe(true)); + expect(onDeleteDesignSystemProject).not.toHaveBeenCalled(); + if (failure === 'missing workspace identity') { + expect(request.mock.calls.some(([url]) => String(url).endsWith('/share-state'))).toBe(false); + } else { + await waitFor(() => expect(request.mock.calls.some(([url]) => String(url).endsWith('/share-state'))).toBe(true)); + } + }, + ); + it('focuses the design-system workspace tab without adding it to file tabs', async () => { const onTabsStateChange = vi.fn(); diff --git a/apps/web/tests/components/ProjectView.artifact-cover-late-arrival.test.tsx b/apps/web/tests/components/ProjectView.artifact-cover-late-arrival.test.tsx index bd8f1b06f6..b42b656ef2 100644 --- a/apps/web/tests/components/ProjectView.artifact-cover-late-arrival.test.tsx +++ b/apps/web/tests/components/ProjectView.artifact-cover-late-arrival.test.tsx @@ -43,7 +43,7 @@ * **在这里量不到**,本文件一个字都没有断言它。那一条只能真机验。 */ -import { act, cleanup, render, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ProjectView } from '../../src/components/ProjectView'; @@ -132,7 +132,10 @@ vi.mock('../../src/components/AvatarMenu', () => ({ AvatarMenu: () => null })); vi.mock('../../src/components/Loading', () => ({ CenteredLoader: () => null })); vi.mock('../../src/components/FileWorkspace', () => ({ DESIGN_SYSTEM_TAB: '__design_system__', - FileWorkspace: () => null, + FileWorkspace: ({ shareRequest }: { shareRequest?: { name: string; nonce: number; anchorId?: string } | null }) => ( + + ), })); /* @@ -142,7 +145,7 @@ vi.mock('../../src/components/FileWorkspace', () => ({ vi.mock('../../src/components/ChatPane', async () => { const { AssistantMessage } = await import('../../src/components/AssistantMessage'); return { - ChatPane: ({ messages }: { messages: ChatMessage[] }) => ( + ChatPane: ({ messages, onArtifactShare }: { messages: ChatMessage[]; onArtifactShare?: (fileName: string, anchorId?: string) => void }) => ( <> {messages .filter((message) => message.role === 'assistant') @@ -152,6 +155,7 @@ vi.mock('../../src/components/ChatPane', async () => { message={message} streaming={false} projectId={PROJECT_ID} + onArtifactShare={onArtifactShare} /> ))} @@ -292,6 +296,19 @@ beforeEach(() => { }); describe('封面在终止帧之后才落地时,卡面必须自己换成快照', () => { + it('F13 real ProjectView card passes a monotonic same-ms named request into FileWorkspace', async () => { + renderProjectView(); + const share = await screen.findByTestId(`artifact-card-publish-${ARTIFACT}`); + expect(share).toBeEnabled(); + const bridge = screen.getByTestId('project-view-share-request'); + vi.spyOn(Date, 'now').mockReturnValue(1710000000000); + for (const nonce of [1710000000000, 1710000000001, 1710000000002]) { + fireEvent.click(share); + await waitFor(() => expect(bridge).toHaveAttribute('data-nonce', String(nonce))); + expect(bridge).toHaveAttribute('data-file', ARTIFACT); + expect(bridge.getAttribute('data-anchor')).toBeTruthy(); + } + }); it('refreshes an already-ready latest cover through three revisions without changing an earlier turn', async () => { const latest = finishedTurn(readyRef)[0]!; const historical: ChatMessage = { diff --git a/apps/web/tests/components/RecentProjectsStrip.share-delete.test.tsx b/apps/web/tests/components/RecentProjectsStrip.share-delete.test.tsx new file mode 100644 index 0000000000..c0b024f0fc --- /dev/null +++ b/apps/web/tests/components/RecentProjectsStrip.share-delete.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { buildWorkspacePermissions, buildWorkspaceSeatSummary, type WorkspaceCollabContext } from '@open-design/contracts'; +import { RecentProjectsStrip } from '../../src/components/RecentProjectsStrip'; +import type { Project } from '../../src/types'; + +const context: WorkspaceCollabContext = { + workspaceId: 'ws', workspaceType: 'personal', workspaceMemberId: 'member', role: 'owner', + memberStatus: 'active', lifecycleState: 'active', billingState: 'active', planId: null, + providerMode: 'platform_credits', seatSummary: buildWorkspaceSeatSummary({ seatLimit: 1, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }), +}; +let workspaceContext: WorkspaceCollabContext | null = context; +vi.mock('../../src/collab/useWorkspaceContext', async (importOriginal) => ({ + ...(await importOriginal>()), + useWorkspaceContext: () => ({ context: workspaceContext, loading: false }), +})); +vi.mock('../../src/providers/registry', () => ({ + fetchProjectFileText: vi.fn(async () => null), fetchProjectFiles: vi.fn(async () => []), + projectFileUrl: (id: string, path: string) => `/api/projects/${id}/files/${path}`, +})); +const project = (id: string, name: string): Project => ({ + id, name, skillId: null, designSystemId: null, createdAt: 1, updatedAt: 2, + status: { value: 'not_started' }, +}); +const remove = vi.fn(async (_id: string) => true); +const request = vi.fn(); +beforeEach(() => { + workspaceContext = context; + remove.mockClear(); request.mockReset(); vi.stubGlobal('fetch', request); + request.mockImplementation(async (url) => { + const path = String(url); + if (path.endsWith('/share-state')) { + const id = path.split('/')[3]; + return Response.json({ projectId: id, hasEverShared: true, bindingExists: true, publications: id === 'project-1' + ? [{ sourceFilePath: 'index.html', slug: 'first', status: 'active' }, { sourceFilePath: 'old.html', slug: 'old', status: 'stopped' }] + : [{ sourceFilePath: 'other.html', slug: 'second', status: 'active' }] }); + } + return new Response('', { status: 200 }); + }); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +it('Drafts batch delete warns for two active links across selected projects and cancel never deletes', async () => { + const { container } = render(); + fireEvent.click(screen.getByRole('button', { name: 'Multi-select' })); + fireEvent.click(container.querySelector('.recent-projects__select-check[aria-label="Landing refresh"]')!); + fireEvent.click(container.querySelector('.recent-projects__select-check[aria-label="Brand system"]')!); + fireEvent.click(screen.getByRole('button', { name: 'Delete selected' })); + const dialog = screen.getByRole('alertdialog'); + expect(await screen.findByText('This project has 2 pages being shared. Deleting it will disable their share links, and visitors will no longer be able to open them.')).toBeVisible(); + expect(remove).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + expect(remove).not.toHaveBeenCalled(); +}); + +it('Drafts batch never deletes when workspace context is missing and no share count is known', () => { + workspaceContext = null; + const { container } = render(); + fireEvent.click(screen.getByRole('button', { name: 'Multi-select' })); + fireEvent.click(container.querySelector('.recent-projects__select-check[aria-label="Landing refresh"]')!); + fireEvent.click(container.querySelector('.recent-projects__select-check[aria-label="Brand system"]')!); + fireEvent.click(screen.getByRole('button', { name: 'Delete selected' })); + const dialog = screen.getByRole('alertdialog'); + expect(within(dialog).getByRole('button', { name: 'Delete selected' })).toBeDisabled(); + expect(within(dialog).getByRole('alert')).toBeVisible(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete selected' })); + expect(remove).not.toHaveBeenCalled(); + expect(request.mock.calls.filter(([url]) => String(url).endsWith('/share-state'))).toHaveLength(0); +}); +it('Drafts single-card delete keeps an exact refusal visible and retryable', async () => { + remove.mockRejectedValueOnce(new Error('Delete permission denied')).mockResolvedValueOnce(true); + render(); + fireEvent.click(screen.getByRole('button', { name: 'More actions' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete' })); + const dialog = screen.getByRole('alertdialog'); + expect(await screen.findByText('This project has 1 pages being shared. Deleting it will disable their share links, and visitors will no longer be able to open them.')).toBeVisible(); + fireEvent.click(screen.getByTestId('project-delete-confirm-accept')); + await waitFor(() => expect(within(dialog).getByRole('alert').textContent).toContain('Delete permission denied')); + expect(dialog.isConnected).toBe(true); + fireEvent.click(screen.getByTestId('project-delete-confirm-accept')); + await waitFor(() => expect(remove).toHaveBeenCalledTimes(2)); +}); diff --git a/apps/web/tests/components/RecentProjectsStrip.type-filter-bulk.test.tsx b/apps/web/tests/components/RecentProjectsStrip.type-filter-bulk.test.tsx index cbfeddeae1..7191ee07a5 100644 --- a/apps/web/tests/components/RecentProjectsStrip.type-filter-bulk.test.tsx +++ b/apps/web/tests/components/RecentProjectsStrip.type-filter-bulk.test.tsx @@ -12,7 +12,8 @@ import { projectKindFilterCategory, } from '../../src/components/RecentProjectsStrip'; import type { Project } from '../../src/types'; -import type { WorkspaceProjectSummary } from '@open-design/contracts'; +import type { WorkspaceProjectSummary, WorkspaceCollabContext } from '@open-design/contracts'; +import { workspaceContextFixture } from '../helpers/workspace-context'; // Typed on the argument the component actually passes, so `.mock.calls` // destructures instead of widening to the empty tuple. @@ -55,6 +56,14 @@ vi.mock('../../src/state/projects', async (importOriginal) => ({ moveWorkspaceProject: (...args: unknown[]) => moveWorkspaceProject(args[0] as MoveCall), })); +let shareReadContext: WorkspaceCollabContext | null = null; +vi.mock('../../src/collab/useWorkspaceContext', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useWorkspaceContext: () => shareReadContext + ? { context: shareReadContext, loading: false } + : actual.useWorkspaceContext() }; +}); + vi.mock('../../src/providers/registry', () => ({ fetchProjectFileText: vi.fn(async () => null), fetchProjectFiles: vi.fn(async () => []), @@ -65,9 +74,21 @@ vi.mock('../../src/providers/registry', () => ({ afterEach(() => { cleanup(); moveWorkspaceProject.mockClear(); + shareReadContext = null; vi.restoreAllMocks(); }); +function provideVerifiedZeroShareCount() { + shareReadContext = workspaceContextFixture({ + workspaceId: 'ws-1', workspaceMemberId: 'wm-1', workspaceType: 'personal', role: 'owner', + }); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { + const match = String(url).match(/^\/api\/projects\/([^/]+)\/share-state$/); + if (!match) return new Response('', { status: 200 }); + return Response.json({ projectId: decodeURIComponent(match[1]!), bindingExists: false, hasEverShared: false, publications: [] }); + }); +} + function project(overrides: Partial): Project { return { id: 'project-1', @@ -462,7 +483,8 @@ describe('RecentProjectsStrip bulk selection bar (#75)', () => { expect(container.querySelector('.recent-projects__bulkbar')).toBeNull(); }); - it('preserves the main no-share batch dialog DOM and cancel does not delete', () => { + it('preserves the main no-share batch dialog DOM and cancel does not delete', async () => { + provideVerifiedZeroShareCount(); // Production markup/copy verified byte-identical to main 8e372744dda5 // before recording; do not regenerate this baseline for share warnings. const onDelete = vi.fn(); @@ -474,6 +496,7 @@ describe('RecentProjectsStrip bulk selection bar (#75)', () => { const bar = enterSelectionMode(container, ['Deck project', 'Media project']); fireEvent.click(within(bar).getByText('Delete selected')); const dialog = screen.getByRole('alertdialog'); + await waitFor(() => expect(within(dialog).getByRole('button', { name: 'Delete selected' })).toBeEnabled()); const titleId = dialog.getAttribute('aria-labelledby')!; expect(dialog.outerHTML.replaceAll(titleId, 'delete-title')).toBe( `

`, @@ -485,6 +508,7 @@ describe('RecentProjectsStrip bulk selection bar (#75)', () => { }); it('confirms before deleting the whole selection', async () => { + provideVerifiedZeroShareCount(); const onDelete = vi.fn((_id: string) => true); const { container } = renderGrid({ canManageProjectCollection: true, @@ -498,6 +522,7 @@ describe('RecentProjectsStrip bulk selection bar (#75)', () => { const dialog = screen.getByRole('alertdialog'); expect(within(dialog).getByText('Delete 2 project(s)?')).toBeTruthy(); expect(onDelete).not.toHaveBeenCalled(); + await waitFor(() => expect(within(dialog).getByRole('button', { name: 'Delete selected' })).toBeEnabled()); fireEvent.click(within(dialog).getByRole('button', { name: 'Delete selected' })); diff --git a/apps/web/tests/components/ShareMoreMenu.test.tsx b/apps/web/tests/components/ShareMoreMenu.test.tsx index cc9c8d92a2..0760da46cf 100644 --- a/apps/web/tests/components/ShareMoreMenu.test.tsx +++ b/apps/web/tests/components/ShareMoreMenu.test.tsx @@ -28,6 +28,10 @@ it('selects once and closes; disabled actions never invoke the callback', () => const select = vi.fn(); const { rerender } = render(); fireEvent.click(screen.getByRole('button', { name: 'More' })); + fireEvent.click(screen.getByRole('button', { name: 'More' })); + expect(screen.queryByRole('menu')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'More' })); + expect(screen.getByRole('menu')).toBeVisible(); fireEvent.click(screen.getByRole('menuitem')); expect(select).toHaveBeenCalledTimes(1); expect(screen.queryByRole('menu')).toBeNull(); diff --git a/apps/web/tests/components/WorkspaceTabsBar.test.tsx b/apps/web/tests/components/WorkspaceTabsBar.test.tsx index 58597f5cbe..09d46e096a 100644 --- a/apps/web/tests/components/WorkspaceTabsBar.test.tsx +++ b/apps/web/tests/components/WorkspaceTabsBar.test.tsx @@ -2039,6 +2039,11 @@ describe('WorkspaceTabsBar dock dropdown project actions', () => { }, }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } + if (url === '/api/projects/' + project.id + '/share-state') { + return new Response(JSON.stringify({ projectId: project.id, bindingExists: false, hasEverShared: false, publications: [] }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } if (url.startsWith('/api/runs?')) { return new Response(JSON.stringify({ runs: [], awaitingInputProjectIds: [] }), { status: 200, @@ -2152,7 +2157,9 @@ describe('WorkspaceTabsBar dock dropdown project actions', () => { expect(screen.queryByTestId('project-delete-confirm-dialog')).toBeNull(); fireEvent.click(within(await openRowMenu()).getByRole('menuitem', { name: 'designs.menuDelete' })); - fireEvent.click(within(await screen.findByTestId('project-delete-confirm-dialog')).getByTestId('project-delete-confirm-accept')); + const accept = within(await screen.findByTestId('project-delete-confirm-dialog')).getByTestId('project-delete-confirm-accept'); + await waitFor(() => expect(accept).toBeEnabled()); + fireEvent.click(accept); await waitFor(() => expect(remove).toHaveBeenCalledWith(project.id)); await waitFor(() => expect(screen.queryByTestId('project-delete-confirm-dialog')).toBeNull()); }); diff --git a/apps/web/tests/components/project-actions/ProjectDeleteConfirmDialog.test.tsx b/apps/web/tests/components/project-actions/ProjectDeleteConfirmDialog.test.tsx new file mode 100644 index 0000000000..5e2297324d --- /dev/null +++ b/apps/web/tests/components/project-actions/ProjectDeleteConfirmDialog.test.tsx @@ -0,0 +1,29 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { ProjectDeleteConfirmDialog } from '../../../src/components/project-actions/ProjectDeleteConfirmDialog'; + +afterEach(cleanup); + +it('keeps S14 button overrides more specific than the legacy global danger rule', () => { + const css = readFileSync('src/components/project-actions/ProjectDeleteConfirmDialog.module.css', 'utf8'); + expect(css).toMatch(/activeShareDialog:global\(\.modal-confirm\)[\s\S]*activeShareFooter:global\(\.row\)[\s\S]*:global\(button\.primary\.danger\)\.activeShareDelete/); + expect(css).toContain('background: #EDEDF0;'); + expect(css).toContain('background: rgb(210, 59, 60);'); + expect(css).toMatch(/activeShareDelete:disabled[\s\S]*?rgb\(210, 59, 60\)/); +}); + +it('uses the S14 active-share confirmation button treatment and cancel never deletes', () => { + const onCancel = vi.fn(); + const onConfirm = vi.fn(); + render(); + const cancel = screen.getByTestId('project-delete-confirm-cancel'); + const remove = screen.getByTestId('project-delete-confirm-accept'); + expect(cancel.className).toMatch(/activeShareCancel/); + expect(remove.className).toMatch(/activeShareDelete/); + expect(screen.getByTestId('project-delete-confirm-dialog').className).toMatch(/activeShareDialog/); + fireEvent.click(cancel); + expect(onCancel).toHaveBeenCalledOnce(); + expect(onConfirm).not.toHaveBeenCalled(); +}); diff --git a/apps/web/tests/components/project-delete-residual-notices.test.tsx b/apps/web/tests/components/project-delete-residual-notices.test.tsx index 58fda3d084..2d81e4de08 100644 --- a/apps/web/tests/components/project-delete-residual-notices.test.tsx +++ b/apps/web/tests/components/project-delete-residual-notices.test.tsx @@ -16,18 +16,46 @@ function Harness() { const state = useDeletedShareNotices(context); return <>; } -it('delivers a real delete response to distinct per-file UI without automatic retries', async () => { +it('renders real deletion failures as global Toast, queues each file, and never retries automatically', async () => { request.mockImplementation(async () => Response.json({ ok: true, shareResiduals: rows })); render(); await act(async () => fireEvent.click(screen.getByText('Delete'))); - expect(screen.getByText('The original file was deleted')).toBeVisible(); - expect(within(screen.getByRole('status')).getByText('auto.html')).toBeVisible(); - expect(within(screen.getByRole('alert')).getByText('manual.html')).toBeVisible(); - expect(within(screen.getByRole('status').closest('li')!).queryByRole('button')).toBeNull(); - expect(screen.getAllByRole('button', { name: 'Retry disabling' })).toHaveLength(1); + const failure = screen.getByRole('alert'); + expect(failure).toHaveClass('od-toast', 'tone-error', 'placement-top'); + expect(within(failure).getByText(/manual\.html.*link could not be disabled/i)).toBeVisible(); + expect(within(failure).getByRole('button', { name: 'Retry disabling' })).toBeEnabled(); + expect(screen.queryByText('auto.html')).toBeNull(); + fireEvent.click(within(failure).getByRole('button', { name: /dismiss/i })); + expect(within(screen.getByRole('status')).getByText(/auto\.html.*being disabled/i)).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Retry disabling' })).toBeNull(); expect(request).toHaveBeenCalledTimes(1); expect(request).toHaveBeenCalledWith('/api/projects/gone', expect.objectContaining({ method: 'DELETE' })); }); +it('S14-ERR manual Toast action retries the exact deleted file and clears only a confirmed stop', async () => { + request.mockResolvedValueOnce(Response.json({ ok: true, shareResiduals: [rows[1]] })) + .mockResolvedValueOnce(Response.json({ status: 'stopped', projectId: 'gone', + filePath: 'manual.html', slug: 'stable-manual' })); + render(); + await act(async () => fireEvent.click(screen.getByText('Delete'))); + expect(request).toHaveBeenCalledTimes(1); + await act(async () => fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry disabling' }))); + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenLastCalledWith('/api/public-file-stops/retry', expect.objectContaining({ + method: 'POST', body: JSON.stringify({ projectId: 'gone', filePath: 'manual.html', slug: 'stable-manual' }), + })); + expect(screen.queryByRole('alert')).toBeNull(); +}); +it('S14-ERR failed manual retry stays visible until dismissed and never fires a second request', async () => { + request.mockResolvedValueOnce(Response.json({ ok: true, shareResiduals: [rows[1]] })) + .mockResolvedValueOnce(Response.json({ error: 'stop not confirmed' }, { status: 503 })); + render(); + await act(async () => fireEvent.click(screen.getByText('Delete'))); + await act(async () => fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry disabling' }))); + expect(within(screen.getByRole('alert')).getByText(/manual\.html.*link could not be disabled/i)).toBeVisible(); + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: /dismiss/i })); + expect(screen.queryByRole('alert')).toBeNull(); + expect(request).toHaveBeenCalledTimes(2); +}); it('retains concurrent deletion groups, delivers once, and removes only a confirmed stopped file', async () => { const { result } = renderHook(() => useDeletedShareNotices(context)); const receive = result.current.capture('gone', context); diff --git a/apps/web/tests/components/project-delete-share-warning.test.tsx b/apps/web/tests/components/project-delete-share-warning.test.tsx index 794e0a2516..4e7006de48 100644 --- a/apps/web/tests/components/project-delete-share-warning.test.tsx +++ b/apps/web/tests/components/project-delete-share-warning.test.tsx @@ -11,7 +11,7 @@ const request = vi.fn(); const remove = vi.fn(async () => true); function Harness() { const flow = useProjectDeleteFlow({ onDelete: remove, analyticsPage: 'home', workspaceContext: context }); - return <>{flow.target ? void flow.commit()} /> : null}; + return <>{flow.target ? void flow.commit()} /> : null}; } beforeEach(() => { request.mockReset(); remove.mockClear(); vi.stubGlobal('fetch', request); }); afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); @@ -37,10 +37,10 @@ it.each<{ statuses: ('active' | 'stopped')[] }>([{ statuses: [] }, { statuses: [ request.mockImplementation(async () => history(statuses)); render(); fireEvent.click(screen.getByText('Request delete')); - const before = screen.getByRole('alertdialog').innerHTML; + expect(screen.getByTestId('project-delete-confirm-accept')).toBeDisabled(); await act(async () => { await Promise.resolve(); }); - expect(screen.getByRole('alertdialog').innerHTML).toBe(before); expect(screen.getByText('Delete "Example"?')).toBeVisible(); + expect(screen.getByTestId('project-delete-confirm-accept')).toBeEnabled(); }); it('does not fabricate a count from an unavailable read', async () => { request.mockResolvedValue(new Response('', { status: 503 })); @@ -50,6 +50,37 @@ it('does not fabricate a count from an unavailable read', async () => { expect(screen.queryByText(/pages being shared/)).toBeNull(); expect(remove).not.toHaveBeenCalled(); }); +it('blocks both the button and direct commit until active links are known', async () => { + let finish!: (response: Response) => void; + request.mockImplementation(() => new Promise(resolve => { finish = resolve; })); + render(); + fireEvent.click(screen.getByText('Request delete')); + const accept = screen.getByTestId('project-delete-confirm-accept'); + expect(accept).toBeDisabled(); + fireEvent.click(accept); + fireEvent.click(screen.getByText('Invoke commit directly')); + expect(remove).not.toHaveBeenCalled(); + await act(async () => finish(history(['active']))); + expect(await screen.findByText(/1 pages being shared/)).toBeVisible(); + expect(accept).toBeEnabled(); + fireEvent.click(accept); + await waitFor(() => expect(remove).toHaveBeenCalledExactlyOnceWith('project')); +}); +it('blocks deletion and visibly reports failed share-state reads, then allows cancel/retry', async () => { + request.mockResolvedValueOnce(new Response('', { status: 503 })).mockResolvedValueOnce(history(['active'])); + render(); + fireEvent.click(screen.getByText('Request delete')); + await waitFor(() => expect(screen.getByRole('alert')).toBeVisible()); + const accept = screen.getByTestId('project-delete-confirm-accept'); + expect(accept).toBeDisabled(); + fireEvent.click(accept); + fireEvent.click(screen.getByText('Invoke commit directly')); + expect(remove).not.toHaveBeenCalled(); + fireEvent.click(screen.getByTestId('project-delete-confirm-cancel')); + fireEvent.click(screen.getByText('Request delete')); + expect(await screen.findByText(/1 pages being shared/)).toBeVisible(); + expect(screen.getByTestId('project-delete-confirm-accept')).toBeEnabled(); +}); it('canceling ignores a late history response and never deletes', async () => { let finish!: (response: Response) => void; request.mockImplementation(() => new Promise(resolve => { finish = resolve; })); diff --git a/apps/web/tests/components/share-request-nonce.test.ts b/apps/web/tests/components/share-request-nonce.test.ts new file mode 100644 index 0000000000..175a9456ab --- /dev/null +++ b/apps/web/tests/components/share-request-nonce.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { nextShareRequestNonce } from '../../src/components/share-request-nonce'; + +describe('Owner artifact-card Share request identity', () => { + it('advances on same-millisecond reopen, even if wall time goes backwards', () => { + const first = nextShareRequestNonce(undefined, 1_730_000_249_000); + const reopened = nextShareRequestNonce(first, 1_730_000_249_000); + const afterClockCorrection = nextShareRequestNonce(reopened, 1_730_000_240_000); + expect(first).toBe(1_730_000_249_000); + expect(reopened).toBe(first + 1); + expect(afterClockCorrection).toBe(reopened + 1); + }); +}); diff --git a/apps/web/tests/components/share/ArtifactPublicationContext.test.tsx b/apps/web/tests/components/share/ArtifactPublicationContext.test.tsx new file mode 100644 index 0000000000..ed36ced19b --- /dev/null +++ b/apps/web/tests/components/share/ArtifactPublicationContext.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { useContext } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ArtifactPublicationContext, ArtifactPublicationProvider } from '../../../src/components/share/ArtifactPublicationContext'; +import { notifyProjectShareHistoryChanged } from '../../../src/components/share/share-publication-events'; +import { currentWorkspaceAccountGeneration, workspaceAccountScopedCacheKey } from '../../../src/collab/workspace-identity'; + +function PublicationProbe({ name }: { name: string }) { + const paths = useContext(ArtifactPublicationContext); + return {paths === null ? 'unknown' : Array.from(paths).join(',')}; +} + +describe('chat artifact publication state', () => { + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it('reads one scoped server history for all cards and revalidates only the changed project after stop', async () => { + let status: 'active' | 'stopped' = 'active'; + let unavailable = false; + const fetchHistory = vi.fn(async () => unavailable ? new Response(null, { status: 503 }) : Response.json({ + projectId: 'project-1', bindingExists: true, hasEverShared: true, + publications: [ + { sourceFilePath: 'pages/one.html', slug: 'one', status }, + { sourceFilePath: 'pages/two.html', slug: 'two', status: 'stopped' }, + ], + })); + vi.stubGlobal('fetch', fetchHistory); + render( + + + ); + + await waitFor(() => expect(screen.getByTestId('first-card')).toHaveTextContent('pages/one.html')); + expect(screen.getByTestId('second-card')).toHaveTextContent('pages/one.html'); + expect(screen.getByTestId('first-card')).not.toHaveTextContent('two.html'); + expect(fetchHistory).toHaveBeenCalledTimes(1); + expect(fetchHistory).toHaveBeenCalledWith('/api/projects/project-1/share-state', expect.objectContaining({ cache: 'no-store' })); + + notifyProjectShareHistoryChanged('another-project'); + expect(fetchHistory).toHaveBeenCalledTimes(1); + unavailable = true; + notifyProjectShareHistoryChanged('project-1'); + await waitFor(() => expect(fetchHistory).toHaveBeenCalledTimes(2)); + expect(screen.getByTestId('first-card')).toHaveTextContent('pages/one.html'); + unavailable = false; + status = 'stopped'; + notifyProjectShareHistoryChanged('project-1'); + await waitFor(() => expect(fetchHistory).toHaveBeenCalledTimes(3)); + await waitFor(() => expect(screen.getByTestId('first-card')).toBeEmptyDOMElement()); + expect(screen.getByTestId('second-card')).toBeEmptyDOMElement(); + }); + + it('removes a confirmed stopped file even if the follow-up server GET fails', async () => { + let unavailable = false; + const fetchHistory = vi.fn(async () => unavailable + ? new Response(null, { status: 503 }) + : Response.json({ projectId: 'project-1', bindingExists: true, hasEverShared: true, publications: [{ sourceFilePath: 'pages/one.html', slug: 'one', status: 'active' }] })); + vi.stubGlobal('fetch', fetchHistory); + render(); + await waitFor(() => expect(screen.getByTestId('card')).toHaveTextContent('pages/one.html')); + unavailable = true; + notifyProjectShareHistoryChanged('project-1', { + sourceFilePath: 'pages/one.html', + accountScope: workspaceAccountScopedCacheKey(null), + generation: currentWorkspaceAccountGeneration(), + }); + await waitFor(() => expect(fetchHistory).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.getByTestId('card')).toBeEmptyDOMElement()); + }); + + it('never carries a confirmed share into a different project or failed initial read', async () => { + const fetchHistory = vi.fn(async (url: string) => url.includes('project-1') + ? Response.json({ projectId: 'project-1', bindingExists: true, hasEverShared: true, publications: [{ sourceFilePath: 'a.html', slug: 'one', status: 'active' }] }) + : new Response(null, { status: 503 })); + vi.stubGlobal('fetch', fetchHistory); + const view = render(); + await waitFor(() => expect(screen.getByTestId('card')).toHaveTextContent('a.html')); + view.rerender(); + expect(screen.getByTestId('card')).toHaveTextContent('unknown'); + await waitFor(() => expect(fetchHistory).toHaveBeenCalledTimes(2)); + expect(screen.getByTestId('card')).toHaveTextContent('unknown'); + }); +}); diff --git a/apps/web/tests/components/share/CommentSyncBanner.test.tsx b/apps/web/tests/components/share/CommentSyncBanner.test.tsx index b467c7c57c..29a903966a 100644 --- a/apps/web/tests/components/share/CommentSyncBanner.test.tsx +++ b/apps/web/tests/components/share/CommentSyncBanner.test.tsx @@ -4,13 +4,14 @@ * Coverage for the three additional CommentSyncBanner branches restored * alongside K8: `shareStopped`, `backfill` (K2), and `align`. Each must * respect the hard semantics documented on `CommentSyncState`: - * - `backfill` absent, `pending`, or `succeeded` never renders a warning. + * - `backfill` absent or succeeded never renders a warning; pending is + * informational, but verified reopened pending warns about stale visible comments. * - `align` absent or `unknown` never renders as aligned OR as a problem. * - `shareStopped: null` (could not be read) never renders as stopped or as * healthy. */ -import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CommentSyncState, WorkspaceCollabContext } from '@open-design/contracts'; import { buildWorkspacePermissions, buildWorkspaceSeatSummary } from '@open-design/contracts'; @@ -47,7 +48,7 @@ function renderBanner(state: CommentSyncState | null) { } beforeEach(() => { fetchMock.mockReset(); vi.stubGlobal('fetch', fetchMock); resetWorkspaceAccountGeneration(); }); -afterEach(() => { cleanup(); vi.unstubAllGlobals(); resetWorkspaceAccountGeneration(); }); +afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); resetWorkspaceAccountGeneration(); }); describe('CommentSyncBanner — restored branches', () => { it('renders nothing when the state is unknown (null)', async () => { @@ -86,29 +87,95 @@ describe('CommentSyncBanner — restored branches', () => { expect(screen.queryByRole('status')).toBeNull(); }); - it('does not render a backfill warning while backfill is pending or already succeeded', async () => { - renderBanner({ ...base, backfill: { state: 'pending', filePath: 'index.html', publicationRevision: 'r1', retryable: false } }); - await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + it('renders first-publication backfill as progress and clears it when sync succeeds', async () => { + vi.useFakeTimers(); + fetchMock.mockImplementationOnce(async () => Response.json({ ...base, backfill: { state: 'pending', filePath: 'index.html', publicationRevision: 'r1', retryable: true, reopened: false } })) + .mockImplementationOnce(async () => Response.json({ ...base, backfill: { state: 'succeeded', filePath: 'index.html', publicationRevision: 'r1', retryable: false, reopened: false } })); + render(); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + expect(screen.getByText('正在同步已有评论,访客暂时可能看不到。')).toBeInTheDocument(); + expect(document.querySelector('[class*="backfillProgress"]')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '重试' })).toBeNull(); + await act(async () => { await vi.advanceTimersByTimeAsync(2_000); }); expect(screen.queryByRole('status')).toBeNull(); - cleanup(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('K5 shows stale deleted/resolved risk while a verified reopened revision is still pending', async () => { + renderBanner({ ...base, backfill: { state: 'pending', filePath: 'index.html', publicationRevision: 'resume-r2', retryable: false, reopened: true } }); + await screen.findByText(/已删除或已处理的评论/); + expect(screen.queryByText('正在同步已有评论,访客暂时可能看不到。')).toBeNull(); + expect(screen.queryByRole('button', { name: '重试' })).toBeNull(); + }); + + it('shows no pending banner for an immediately succeeded empty-comment backfill', async () => { renderBanner({ ...base, backfill: { state: 'succeeded', filePath: 'index.html', publicationRevision: 'r1', retryable: false } }); await waitFor(() => expect(fetchMock).toHaveBeenCalled()); expect(screen.queryByRole('status')).toBeNull(); }); - it('renders the approved backfill-failed copy with a retry button when not auto-retrying', async () => { - renderBanner({ ...base, backfill: { state: 'failed', filePath: 'index.html', publicationRevision: 'r1', retryable: false } }); - await screen.findByText('分享已发布'); - await screen.findByText('已有评论暂未同步,访客暂时看不到'); + it('polls an automatically retrying backfill until success without focus or manual retry', async () => { + vi.useFakeTimers(); + fetchMock.mockImplementationOnce(async () => Response.json({ ...base, backfill: { state: 'failed', filePath: 'index.html', publicationRevision: 'r1', retryable: true } })) + .mockImplementationOnce(async () => Response.json({ ...base, backfill: { state: 'succeeded', filePath: 'index.html', publicationRevision: 'r1', retryable: false } })); + render(); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + expect(screen.getByText('分享已发布')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '重试' })).toBeInTheDocument(); + await act(async () => { await vi.advanceTimersByTimeAsync(2_000); }); + expect(screen.queryByRole('status')).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(2); }); - it('omits the manual retry button when the server is already auto-retrying (retryable: true)', async () => { - renderBanner({ ...base, backfill: { state: 'failed', filePath: 'index.html', publicationRevision: 'r1', retryable: true } }); + it('shows an accurate terminal backfill outcome without offering a manual retry', async () => { + renderBanner({ ...base, backfill: { state: 'failed', filePath: 'index.html', publicationRevision: 'r1', retryable: false } }); await screen.findByText('分享已发布'); + await screen.findByText('部分已有评论未能同步到分享页,系统不会自动重试。分享链接仍可使用。'); expect(screen.queryByRole('button', { name: '重试' })).toBeNull(); }); + it('K5 terminal failure retains the original link-risk warning without pretending automatic retry continues', async () => { + renderBanner({ ...base, backfill: { state: 'failed', filePath: 'index.html', publicationRevision: 'resume-r2', retryable: false, reopened: true } }); + await screen.findByText(/已删除或已处理的评论/); + expect(screen.getByText(/自动重试已停止/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '重试' })).toBeNull(); + }); + + it('keeps retry disabled while the POST is pending and reports a failed POST', async () => { + let release!: (response: Response) => void; + const post = new Promise(resolve => { release = resolve; }); + fetchMock.mockImplementationOnce(async () => Response.json({ ...base, backfill: { state: 'failed', filePath: 'index.html', publicationRevision: 'r1', retryable: true } })) + .mockImplementationOnce(async (_input, init) => { + expect(init?.method).toBe('POST'); + return post; + }) + .mockImplementation(async () => Response.json({ ...base, backfill: { state: 'failed', filePath: 'index.html', publicationRevision: 'r1', retryable: true } })); + render(); + const retry = await screen.findByRole('button', { name: '重试' }); + await act(async () => { retry.click(); }); + expect(retry).toBeDisabled(); + await act(async () => { release(new Response(null, { status: 503 })); }); + expect(await screen.findByRole('alert')).toHaveTextContent('已有评论暂未同步,访客暂时看不到'); + expect(screen.getByRole('button', { name: '重试' })).toBeEnabled(); + }); + it('offers retry for retryable failed backfill and reloads server state after POST', async () => { + fetchMock.mockImplementationOnce(async () => Response.json({ ...base, backfill: { state: 'failed', filePath: 'index.html', publicationRevision: 'r1', retryable: true } })) + .mockImplementationOnce(async (_input, init) => { + expect(init?.method).toBe('POST'); + return Response.json({ ...base, backfill: { state: 'failed', filePath: 'index.html', publicationRevision: 'r1', retryable: true } }); + }) + .mockImplementationOnce(async () => Response.json({ ...base, backfill: { state: 'succeeded', filePath: 'index.html', publicationRevision: 'r1', retryable: false } })); + render(); + const retry = await screen.findByRole('button', { name: '重试' }); + await act(async () => { retry.click(); }); + expect(fetchMock.mock.calls.map(([url, options]) => [url, options?.method])).toEqual([ + ['/api/projects/p/comment-sync-state?filePath=index.html', undefined], + ['/api/projects/p/comment-sync-state?filePath=index.html', 'POST'], + ['/api/projects/p/comment-sync-state?filePath=index.html', undefined], + ]); + await waitFor(() => expect(screen.queryByRole('status')).toBeNull()); + }); + it('does not render an align warning for absent or unknown align (not checked is not a verdict)', async () => { renderBanner({ ...base, align: undefined }); await waitFor(() => expect(fetchMock).toHaveBeenCalled()); diff --git a/apps/web/tests/components/share/ShareMoreMenu.test.tsx b/apps/web/tests/components/share/ShareMoreMenu.test.tsx new file mode 100644 index 0000000000..8704878e76 --- /dev/null +++ b/apps/web/tests/components/share/ShareMoreMenu.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ShareMoreMenu } from '../../../src/components/share/ShareMoreMenu'; + +const originalClientWidth = Object.getOwnPropertyDescriptor(document.documentElement, 'clientWidth'); +const originalInnerHeight = Object.getOwnPropertyDescriptor(window, 'innerHeight'); +afterEach(() => { + cleanup(); + if (originalClientWidth) Object.defineProperty(document.documentElement, 'clientWidth', originalClientWidth); + else Reflect.deleteProperty(document.documentElement, 'clientWidth'); + if (originalInnerHeight) Object.defineProperty(window, 'innerHeight', originalInnerHeight); + else Reflect.deleteProperty(window, 'innerHeight'); + vi.restoreAllMocks(); +}); + +describe('Owner S12 deployment menu', () => { + it('keeps deployment items in the viewport beside an edge trigger without removing the link-access explanation', () => { + Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 320 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 700 }); + const selectVercel = vi.fn(); + render(<> undefined }, + ]} />

Link access details

); + const trigger = screen.getByRole('button', { name: 'More sharing options' }); + const triggerRect = vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + x: 10, y: 40, left: 10, top: 40, right: 30, bottom: 60, width: 20, height: 20, + toJSON: () => ({}), + }); + fireEvent.click(trigger); + const menu = screen.getByRole('menu'); + expect(screen.getByRole('menuitem', { name: 'Deploy to Vercel' })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: 'Deploy to Cloudflare Pages' })).toBeVisible(); + expect(menu.style.position).toBe('fixed'); + expect(Number.parseFloat(menu.style.left)).toBeGreaterThanOrEqual(8); + expect(Number.parseFloat(menu.style.left) + Number.parseFloat(menu.style.width)).toBeLessThanOrEqual(312); + expect(menu.style.left).toBe('34px'); + expect(screen.getByText('Link access details')).toBeVisible(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Deploy to Vercel' })); + expect(selectVercel).toHaveBeenCalledOnce(); + expect(trigger).toHaveFocus(); + triggerRect.mockReturnValue({ + x: 290, y: 40, left: 290, top: 40, right: 310, bottom: 60, width: 20, height: 20, + toJSON: () => ({}), + }); + fireEvent.click(trigger); + expect(screen.getByRole('menu').style.left).toBe('72px'); + }); + + it('opens beside the panel instead of covering link-access copy when both sides fit', () => { + Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 960 }); + render(<> undefined }, + { id: 'cloudflare', label: 'Deploy to Cloudflare Pages', onSelect: () => undefined }, + ]} />

Link access details

); + const trigger = screen.getByRole('button', { name: 'More sharing options' }); + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + x: 500, y: 40, left: 500, top: 40, right: 520, bottom: 60, width: 20, height: 20, + toJSON: () => ({}), + }); + const explanation = screen.getByText('Link access details'); + vi.spyOn(explanation, 'getBoundingClientRect').mockReturnValue({ + x: 280, y: 65, left: 280, top: 65, right: 490, bottom: 90, width: 210, height: 25, + toJSON: () => ({}), + }); + fireEvent.click(trigger); + const menu = screen.getByRole('menu'); + const menuLeft = Number.parseFloat(menu.style.left); + expect(menuLeft).toBeGreaterThanOrEqual(explanation.getBoundingClientRect().right + 4); + expect(menuLeft).toBe(524); + expect(menuLeft + Number.parseFloat(menu.style.width)).toBeLessThanOrEqual(952); + }); + + it('dismisses with Escape and outside pointer, restoring focus on Escape', () => { + render(<> undefined }, + { id: 'cloudflare', label: 'Deploy to Cloudflare Pages', onSelect: () => undefined }, + ]} />); + const trigger = screen.getByRole('button', { name: 'More sharing options' }); + fireEvent.click(trigger); + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }); + expect(screen.queryByRole('menuitem', { name: 'Deploy to Vercel' })).toBeNull(); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(trigger).toHaveFocus(); + fireEvent.click(trigger); + fireEvent.pointerDown(screen.getByRole('button', { name: 'Outside' })); + expect(screen.queryByRole('menuitem', { name: 'Deploy to Vercel' })).toBeNull(); + }); +}); diff --git a/apps/web/tests/components/share/ShareTab.copy-button.test.tsx b/apps/web/tests/components/share/ShareTab.copy-button.test.tsx index b85b803f78..04d2268f66 100644 --- a/apps/web/tests/components/share/ShareTab.copy-button.test.tsx +++ b/apps/web/tests/components/share/ShareTab.copy-button.test.tsx @@ -2,17 +2,25 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import type { ComponentProps } from 'react'; -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildWorkspacePermissions, buildWorkspaceSeatSummary, type WorkspaceCollabContext } from '@open-design/contracts'; import { parse } from 'postcss'; import { ShareTab } from '../../../src/components/share/ShareTab'; +import { I18nProvider } from '../../../src/i18n'; -afterEach(cleanup); +afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); }); type Props = ComponentProps; +const authenticatedWorkspace: WorkspaceCollabContext = { + workspaceId: 'ws', workspaceType: 'personal', workspaceMemberId: 'member', role: 'owner', + memberStatus: 'active', lifecycleState: 'active', billingState: 'active', planId: null, providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 1, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }), +}; function props(overrides: Partial = {}): Props { return { - menuOrigin: 'artifact-card', workspaceContext: null, t: (key) => key, + menuOrigin: 'artifact-card', workspaceContext: authenticatedWorkspace, t: (key) => key, shareAccess: 'private', shareAccessMenuOpen: false, shareAccessBusy: false, viewerOnly: false, setShareAccessMenuOpen: vi.fn(), setWorkspaceShareAccess: vi.fn(), canPublishPublic: true, filePublished: true, publishedFileUrl: 'https://example.test/artifact/p/s', @@ -30,11 +38,65 @@ function props(overrides: Partial = {}): Props { const checkPath = 'm3 8 3 3 7-7'; const linkPath = 'M10 13.5a5 5 0 0 0 7 .2l3-3a5 5 0 0 0-7-7l-1.7 1.7M14 10.5a5 5 0 0 0-7-.2l-3 3a5 5 0 0 0 7 7l1.7-1.7'; +describe('S0/S13 signed-out Share panel', () => { + it('starts and cancels the existing Vela login from the S0 CTA without publishing', async () => { + const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/integrations/vela/status')) return new Response(JSON.stringify({ loggedIn: false, loginInFlight: false, profile: 'prod', user: null, configPath: '/x' }), { status: 200 }); + if (url.endsWith('/api/integrations/vela/login') && init?.method === 'POST') return new Response(JSON.stringify({ pid: 4242 }), { status: 202 }); + if (url.endsWith('/api/integrations/vela/login/cancel') && init?.method === 'POST') return new Response(JSON.stringify({ canceled: true, pids: [4242] }), { status: 200 }); + throw new Error(`unexpected request: ${url}`); + }); + vi.stubGlobal('fetch', fetch); + const input = props({ canPublishPublic: false, filePublished: false, publishedFileUrl: '' }); + render(); + fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + await waitFor(() => expect(fetch.mock.calls.filter(([url, init]) => String(url).endsWith('/api/integrations/vela/login') && init?.method === 'POST')).toHaveLength(1)); + fireEvent.click(screen.getByRole('button', { name: /cancel sign-in/i })); + await waitFor(() => expect(fetch.mock.calls.filter(([url, init]) => String(url).endsWith('/api/integrations/vela/login/cancel') && init?.method === 'POST')).toHaveLength(1)); + expect(input.publishCurrentFilePublic).not.toHaveBeenCalled(); + }); + + it('explains why an unbound workspace cannot publish and offers a real sign-in entry, not an empty shell', () => { + const input = props({ canPublishPublic: false, filePublished: false, publishedFileUrl: '' }); + render(); + expect(screen.getByText('fileViewer.publishFileRequiresWorkspace')).toBeVisible(); + expect(screen.getByRole('button', { name: /sign in/i })).toBeEnabled(); + expect(screen.queryByRole('switch', { name: 'fileViewer.linkAccessTitle' })).toBeNull(); + expect(input.publishCurrentFilePublic).not.toHaveBeenCalled(); + }); + + it('keeps a retained signed-out publication copyable while disabling its mutation controls', async () => { + const input = props({ + workspaceContext: null, + canPublishPublic: true, + publicationStatus: 'active', + publicationFreshness: 'outdated', + updateCurrentFilePublic: vi.fn().mockResolvedValue(undefined), + }); + render(); + + expect(screen.getByText(input.publishedFileUrl)).toBeVisible(); + const accessSwitch = screen.getByRole('switch', { name: 'fileViewer.linkAccessTitle' }); + const updateButton = screen.getByRole('button', { name: 'fileViewer.shareUpdateLink' }); + expect(accessSwitch).toBeDisabled(); + expect(updateButton).toBeDisabled(); + fireEvent.click(accessSwitch); + fireEvent.click(updateButton); + expect(input.unpublishCurrentFilePublic).not.toHaveBeenCalled(); + expect(input.updateCurrentFilePublic).not.toHaveBeenCalled(); + + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'fileViewer.copyShareLink' }))); + expect(input.copyPublishedFileLink).toHaveBeenCalledTimes(1); + expect(screen.getByText(input.publishedFileUrl)).toBeVisible(); + }); +}); + describe('S3/S4/S4-C copy-button rendering seam', () => { it.each(['toolbar', 'artifact-card'] as const)('keeps read-only %s links copyable but prevents stopping them', async menuOrigin => { const input = props({ menuOrigin, viewerOnly: true }); const { rerender } = render(); - const stop = screen.getByRole('button', { name: 'fileViewer.unpublishFile' }); + const stop = screen.getByRole('switch', { name: 'fileViewer.linkAccessTitle' }); expect(stop).toBeDisabled(); expect(stop).toHaveAttribute('title', 'read only'); fireEvent.click(stop); @@ -70,7 +132,7 @@ describe('S3/S4/S4-C copy-button rendering seam', () => { expect(input.unpublishCurrentFilePublic).not.toHaveBeenCalled(); await act(async () => { finish(); await pending; }); rerender(); - const settled = screen.getByRole('button', { name: feedback === 'copied' ? 'fileViewer.copied' : 'fileViewer.copyShareLink' }); + const settled = screen.getByRole('button', { name: feedback === 'copied' ? 'preview.shareCopied' : 'fileViewer.copyShareLink' }); expect(settled).toBeEnabled(); expect(settled).not.toHaveAttribute('aria-busy'); if (feedback === 'failed') expect(screen.getByRole('status')).toHaveTextContent('fileViewer.copyLinkManually'); @@ -80,7 +142,7 @@ describe('S3/S4/S4-C copy-button rendering seam', () => { }); it.each([null, 'copied', 'failed'] as const)('renders only the matching icon for feedback %s', (feedback) => { render(); - const label = feedback === 'copied' ? 'fileViewer.copied' : 'fileViewer.copyShareLink'; + const label = feedback === 'copied' ? 'preview.shareCopied' : 'fileViewer.copyShareLink'; const button = screen.getByRole('button', { name: label }); expect(button.className).toContain('copyButton'); const icon = button.querySelector('svg')!; @@ -95,29 +157,31 @@ describe('S3/S4/S4-C copy-button rendering seam', () => { expect(icon).toHaveAttribute('aria-hidden', 'true'); expect(icon.querySelector('path')).toHaveAttribute('d', feedback === 'copied' ? checkPath : linkPath); expect(icon.classList.toString().includes('copiedIcon')).toBe(feedback === 'copied'); - expect(screen.getByRole('button', { name: 'fileViewer.unpublishFile' }).className).not.toContain('copyButton'); + expect(screen.getByRole('switch', { name: 'fileViewer.linkAccessTitle' }).className).not.toContain('copyButton'); }); - it('scopes the selectable S11 URL fallback to failed clipboard feedback', () => { - const input = props({ publishLinkFeedback: 'failed' }); + it('keeps the full selectable share URL visible through idle, copied, and clipboard-failure states', () => { + const input = props(); const { rerender } = render(); - expect(screen.getByText(input.publishedFileUrl).className).toContain('copyFallback'); - expect(screen.getByText(input.publishedFileUrl)).toHaveAttribute('title', input.publishedFileUrl); + const url = screen.getByText(input.publishedFileUrl); + expect(url).toBeVisible(); + expect(url).toHaveAttribute('title', input.publishedFileUrl); + expect(url.querySelector('svg')).toHaveAttribute('width', '12'); expect(screen.queryByRole('textbox')).toBeNull(); - for (const feedback of [null, 'copied'] as const) { + for (const feedback of ['copied', 'failed', null] as const) { rerender(); - expect(screen.getByText(input.publishedFileUrl).className).not.toContain('copyFallback'); + if (feedback === 'failed') expect(screen.getByRole('status')).toHaveTextContent('fileViewer.copyLinkManually'); } }); - it('declares the S11 fallback geometry without changing the global URL style', () => { + it('styles the always-visible URL row to the final 32px link geometry', () => { const css = parse(readFileSync(resolve(__dirname, '../../../src/components/share/ShareTab.module.css'), 'utf8')); const values: Record = {}; - css.walkRules('.copyFallback:global(.chrome-publish-url)', rule => { + css.walkRules('.publishedLink :global(.chrome-publish-url)', rule => { rule.walkDecls(decl => { values[decl.prop] = decl.value; }); }); expect(values).toMatchObject({ - height: '32px', padding: '0 9px', border: '1px solid #E5E5E5', + height: '32px', padding: '0 9px 0 29px', border: '1px solid #E5E5E5', 'border-radius': '6px', background: '#FFFFFF', color: '#666666', 'font-size': '11px', 'line-height': '30px', 'user-select': 'text', 'white-space': 'nowrap', 'text-overflow': 'ellipsis', @@ -140,13 +204,76 @@ describe('S3/S4/S4-C copy-button rendering seam', () => { } }); + it('S3/S4 keep the URL visible alongside copied feedback and preserve Help and Stop', () => { + const input = props({ publishLinkFeedback: 'copied' }); + const { rerender } = render( key} />); + const link = screen.getByText(input.publishedFileUrl); + expect(link).toBeVisible(); + expect(link).not.toHaveAttribute('hidden'); + expect(screen.getByRole('button', { name: 'preview.shareCopied' }).parentElement).toHaveClass('chrome-publish-actions'); + const stop = screen.getByRole('switch', { name: 'fileViewer.linkAccessTitle' }); + expect(stop).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(stop); + expect(input.unpublishCurrentFilePublic).toHaveBeenCalledTimes(1); + rerender( key} />); + expect(link).toBeVisible(); + expect(link).not.toHaveAttribute('hidden'); + expect(screen.getByRole('status')).toHaveTextContent('fileViewer.copyLinkManually'); + }); + + it('uses a 32px full-width link and copy stack with an 8px gap, hides completed progress, and retains stop controls', () => { + const input = props({ publishProgress: 0.9 }); + const { rerender } = render(); + expect(screen.queryByRole('progressbar')).toBeNull(); + const link = screen.getByText(input.publishedFileUrl); + const stack = link.parentElement!; + expect(stack.className).toContain('publishedLink'); + expect(screen.getByRole('button', { name: 'fileViewer.copyShareLink' })).toBeVisible(); + expect(screen.getByRole('switch', { name: 'fileViewer.linkAccessTitle' })).toBeVisible(); + expect(screen.getByRole('switch', { name: 'fileViewer.linkAccessTitle' })).toBeVisible(); + const css = parse(readFileSync(resolve(__dirname, '../../../src/components/share/ShareTab.module.css'), 'utf8')); + const declarations = (selector: string) => { + const values: Record = {}; + css.walkRules(selector, rule => rule.walkDecls(decl => { values[decl.prop] = decl.value; })); + return values; + }; + expect(declarations('.publishedLink')).toMatchObject({ gap: '8px', padding: '0' }); + expect(declarations('.publishedLink :global(.chrome-publish-url)')).toMatchObject({ width: '100%', height: '32px' }); + expect(declarations('.publishedLink :global(.chrome-publish-actions)')).toMatchObject({ display: 'block', width: '100%' }); + expect(declarations('button.copyButton')).toMatchObject({ width: '100%', height: '32px' }); + rerender(); + expect(screen.getAllByRole('progressbar')).toHaveLength(1); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-label', 'fileViewer.uploadingFile'); + rerender(); + expect(screen.queryByRole('progressbar')).toBeNull(); + }); + + it('opens only HTTP(S) deployment links while allowing configured custom domains', () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null); + try { + const input = props({ menuOrigin: 'toolbar', sharePageUrl: 'https://preview.example.test/page', canOpenSharePage: true }); + const { rerender } = render(); + fireEvent.click(screen.getByRole('menuitem', { name: 'fileViewer.openSharePage' })); + expect(open).toHaveBeenCalledWith('https://preview.example.test/page', '_blank', 'noopener,noreferrer'); + open.mockClear(); + for (const sharePageUrl of ['javascript:alert(1)', 'data:text/html,unsafe', 'http://[invalid']) { + rerender(); + fireEvent.click(screen.getByRole('menuitem', { name: 'fileViewer.openSharePage' })); + expect(open).not.toHaveBeenCalled(); + } + } finally { + open.mockRestore(); + } + }); + + it('keeps copy and stop callbacks separate and the URL read-only', () => { const input = props(); render(); fireEvent.click(screen.getByRole('button', { name: 'fileViewer.copyShareLink' })); expect(input.copyPublishedFileLink).toHaveBeenCalledTimes(1); expect(input.unpublishCurrentFilePublic).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole('button', { name: 'fileViewer.unpublishFile' })); + fireEvent.click(screen.getByRole('switch', { name: 'fileViewer.linkAccessTitle' })); expect(input.unpublishCurrentFilePublic).toHaveBeenCalledTimes(1); expect(input.publishCurrentFilePublic).not.toHaveBeenCalled(); expect(screen.getByText(input.publishedFileUrl)).toHaveAttribute('title', input.publishedFileUrl); @@ -165,7 +292,7 @@ describe('S3/S4/S4-C copy-button rendering seam', () => { expect(copy.getAttribute('title')).toBe(state.streaming ? 'fileViewer.shareAfterGenerationComplete' : null); fireEvent.click(copy); expect(input.copyPublishedFileLink).toHaveBeenCalledTimes(state.streaming ? 0 : 1); - expect((screen.getByRole('button', { name: 'fileViewer.unpublishFile' }) as HTMLButtonElement).disabled) + expect((screen.getByRole('switch', { name: 'fileViewer.linkAccessTitle' }) as HTMLButtonElement).disabled) .toBe(state.viewerOnly || state.publishingPublicFile); }); @@ -175,7 +302,7 @@ describe('S3/S4/S4-C copy-button rendering seam', () => { for (const feedback of [null, 'failed'] as const) { rerender(); expect(document.querySelector('path[d="' + checkPath + '"]')).toBeNull(); - expect(screen.queryByRole('button', { name: 'fileViewer.copied' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'preview.shareCopied' })).toBeNull(); } }); diff --git a/apps/web/tests/components/share/ShareTab.first-publish.test.tsx b/apps/web/tests/components/share/ShareTab.first-publish.test.tsx index 38e4b855b3..87b56af23c 100644 --- a/apps/web/tests/components/share/ShareTab.first-publish.test.tsx +++ b/apps/web/tests/components/share/ShareTab.first-publish.test.tsx @@ -2,18 +2,25 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import type { ComponentProps } from 'react'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildWorkspacePermissions, buildWorkspaceSeatSummary, type WorkspaceCollabContext } from '@open-design/contracts'; import { parse } from 'postcss'; import { zhCN } from '../../../src/i18n/locales/zh-CN'; import { ShareTab } from '../../../src/components/share/ShareTab'; -afterEach(cleanup); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); type Props = ComponentProps; +const authenticatedWorkspace: WorkspaceCollabContext = { + workspaceId: 'ws', workspaceType: 'personal', workspaceMemberId: 'member', role: 'owner', + memberStatus: 'active', lifecycleState: 'active', billingState: 'active', planId: null, providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 1, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }), +}; function props(overrides: Partial = {}): Props { return { - menuOrigin: 'artifact-card', workspaceContext: null, t: (key) => key, + menuOrigin: 'artifact-card', workspaceContext: authenticatedWorkspace, t: (key) => key, shareAccess: 'private', shareAccessMenuOpen: false, shareAccessBusy: false, viewerOnly: false, setShareAccessMenuOpen: vi.fn(), setWorkspaceShareAccess: vi.fn(), canPublishPublic: true, filePublished: true, publishedFileUrl: 'https://example.test/artifact/p/s', @@ -85,6 +92,26 @@ describe('S1 first-publish visual seam', () => { const { container } = render(); expect(container.firstElementChild?.className).toContain('panel'); }); + it('S1 starts link-access selected without publishing and only the primary action creates the link', () => { + const input = firstProps(); + render(); + const toggle = screen.getByRole('switch', { name: 'fileViewer.linkAccessTitle' }); + const generate = screen.getByRole('menuitem', { name: 'fileViewer.generateAndCopyLink' }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(screen.queryByText(input.publishedFileUrl)).toBeNull(); + expect(input.publishCurrentFilePublic).not.toHaveBeenCalled(); + expect(input.unpublishCurrentFilePublic).not.toHaveBeenCalled(); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(generate).toBeDisabled(); + expect(input.publishCurrentFilePublic).not.toHaveBeenCalled(); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(input.publishCurrentFilePublic).not.toHaveBeenCalled(); + fireEvent.click(generate); + expect(input.publishCurrentFilePublic).toHaveBeenCalledTimes(1); + }); + it('binds the canvas button style, Chinese label and 13px upload icon', () => { render( zhCN[key] })} />); const button = screen.getByRole('menuitem', { name: '生成并复制链接' }); @@ -97,6 +124,51 @@ describe('S1 first-publish visual seam', () => { expect(icon.querySelector('path')).toHaveAttribute('d', uploadPath); }); + it('shows expandable missing-reference warning without disabling publish', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ + fileCount: 1, totalBytes: 12, exceedsSizeLimit: false, + exclusions: [{ path: 'assets/missing.png', reason: 'missing' }], + }) })); + const publish = vi.fn().mockResolvedValue(undefined); + render(); + await waitFor(() => expect(screen.getByText('fileViewer.shareMissingRefs')).toBeInTheDocument()); + const button = screen.getByRole('menuitem', { name: 'fileViewer.generateAndCopyLink' }); + expect(button).toBeEnabled(); + fireEvent.click(screen.getByRole('button', { name: /fileViewer.shareMissingRefsToggle/ })); + expect(screen.getByText('assets/missing.png')).toBeInTheDocument(); + fireEvent.click(button); + expect(publish).toHaveBeenCalledTimes(1); + }); + + it('reports preflight failure accessibly while requiring explicit generation after selection', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); + const publish = vi.fn().mockResolvedValue(undefined); + render(); + const toggle = screen.getByRole('switch', { name: 'fileViewer.linkAccessTitle' }); + expect(toggle).toBeDisabled(); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('fileViewer.sharePlanUnavailable')); + expect(toggle).toBeEnabled(); + const generate = screen.getByRole('menuitem', { name: 'fileViewer.generateAndCopyLink' }); + expect(generate).toBeEnabled(); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(generate).toBeDisabled(); + expect(publish).not.toHaveBeenCalled(); + fireEvent.click(toggle); + fireEvent.click(generate); + expect(publish).toHaveBeenCalledTimes(1); + }); + + it('keeps the link-access switch disabled only while preflight is pending', async () => { + let resolveFetch!: (value: unknown) => void; + vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { resolveFetch = resolve; }))); + render(); + const toggle = screen.getByRole('switch', { name: 'fileViewer.linkAccessTitle' }); + expect(toggle).toBeDisabled(); + resolveFetch({ ok: true, json: async () => ({ fileCount: 1, totalBytes: 4, exceedsSizeLimit: false, exclusions: [] }) }); + await waitFor(() => expect(toggle).toBeEnabled()); + }); + it('uses only the existing publish callback and leaves deployment outside the seam', () => { const input = firstProps({ menuOrigin: 'toolbar' }); render(); @@ -139,6 +211,15 @@ describe('S1 first-publish visual seam', () => { expect(input.publishCurrentFilePublic).toHaveBeenCalledTimes(1); }); + it('preserves an observed public link on workspace auth loss without allowing mutation', () => { + const copy = vi.fn().mockResolvedValue(undefined); + render(); + expect(screen.getByText('https://example.test/previous')).toBeVisible(); + expect(screen.getByRole('switch')).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'fileViewer.copyShareLink' })); + expect(copy).toHaveBeenCalledOnce(); + }); + it('keeps the public-publish gate', () => { render(); expect(screen.queryByRole('menuitem')).toBeNull(); diff --git a/apps/web/tests/components/share/ShareTab.publish-error.test.tsx b/apps/web/tests/components/share/ShareTab.publish-error.test.tsx index 8b1b4cb729..7a0889bcaa 100644 --- a/apps/web/tests/components/share/ShareTab.publish-error.test.tsx +++ b/apps/web/tests/components/share/ShareTab.publish-error.test.tsx @@ -1,10 +1,7 @@ // @vitest-environment jsdom -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; import type { ComponentProps } from 'react'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { parse } from 'postcss'; import { zhCN } from '../../../src/i18n/locales/zh-CN'; import { ShareTab } from '../../../src/components/share/ShareTab'; @@ -33,8 +30,11 @@ describe('S7 publish failure visual seam', () => { it.each(['artifact-card', 'toolbar'] as const)('offers valid recovery from %s without adding deployment controls', (menuOrigin) => { const input = props({ menuOrigin, filePublished: false, publishFailureKey: 'fileViewer.publishFileFailed', t: (key) => zhCN[key] }); const { rerender } = render(); - expect(screen.getByRole('status')).toHaveTextContent('生成分享链接失败,请稍后重试。'); - fireEvent.click(screen.getByRole('menuitem', { name: zhCN['preview.retry'] })); + const status = screen.getByRole('status'); + expect(status).toHaveTextContent('上传失败,请检查网络后重试。'); + const retry = screen.getByRole('menuitem', { name: zhCN['preview.retry'] }); + expect(status.compareDocumentPosition(retry) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + fireEvent.click(retry); expect(input.publishCurrentFilePublic).toHaveBeenCalledTimes(1); rerender(); expect(screen.queryByRole('status')).toBeNull(); @@ -44,23 +44,13 @@ describe('S7 publish failure visual seam', () => { expect(screen.queryByRole('menuitem', { name: zhCN['preview.retry'] })).toBeNull(); expect(screen.getByRole('button', { name: zhCN['fileViewer.copyShareLink'] })).toBeEnabled(); }); - it.each(['fileViewer.publishFileFailed', 'fileViewer.publishFileTooLarge'] as const)('renders the canvas warning icon without changing %s copy or retry', (publishFailureKey) => { + it.each(['fileViewer.publishFileFailed', 'fileViewer.publishFileTooLarge'] as const)('shows %s and preserves retry without copying', (publishFailureKey) => { const input = props({ filePublished: false, publishFailureKey, t: (key) => zhCN[key] }); render(); const status = screen.getByRole('status'); expect(status.textContent).toBe(zhCN[publishFailureKey]); - expect(status.className).toContain('publishError'); - expect(status.className).not.toContain('chrome-publish-error'); - const icon = status.querySelector('svg')!; - expect(icon).not.toBeNull(); - for (const [name, value] of Object.entries({ width: '14', height: '14', viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.5', 'stroke-linecap': 'round', 'aria-hidden': 'true', focusable: 'false' })) { - expect(icon).toHaveAttribute(name, value); - } - expect(icon.querySelector('circle')).toHaveAttribute('cx', '8'); - expect(icon.querySelector('circle')).toHaveAttribute('cy', '8'); - expect(icon.querySelector('circle')).toHaveAttribute('r', '6.2'); - expect(icon.querySelector('path')).toHaveAttribute('d', 'M8 4.8v3.6M8 11h.01'); const retry = screen.getByRole('menuitem', { name: zhCN['preview.retry'] }); + expect(status.compareDocumentPosition(retry) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(retry).toBeEnabled(); fireEvent.click(retry); expect(input.publishCurrentFilePublic).toHaveBeenCalledTimes(1); @@ -68,11 +58,11 @@ describe('S7 publish failure visual seam', () => { }); it('keeps generic and size messages distinct, without attributing unknown failures to the network', () => { - expect(zhCN['fileViewer.publishFileFailed']).toBe('生成分享链接失败,请稍后重试。'); + expect(zhCN['fileViewer.publishFileFailed']).toBe('上传失败,请检查网络后重试。'); expect(zhCN['fileViewer.publishFileTooLarge']).toBe('项目超过 20 MiB 分享上限。请减小 HTML 和引用资源的总大小后重试。'); }); - it.each([{ publishFailureKey: null }, { canPublishPublic: false }])('omits the error when absent or gated: %j', (overrides) => { + it.each([{ publishFailureKey: null }, { canPublishPublic: false, filePublished: false }])('omits the error when absent or gated: %j', (overrides) => { render(); expect(screen.queryByRole('status')).toBeNull(); }); @@ -87,14 +77,4 @@ describe('S7 publish failure visual seam', () => { expect(screen.getByRole('status')).toHaveTextContent('fileViewer.publishFileFailed'); }); - it('declares local canvas error geometry independent of legacy global error rules', () => { - const css = parse(readFileSync(resolve(__dirname, '../../../src/components/share/ShareTab.module.css'), 'utf8')); - const declarations = (selector: string) => { - const values: Record = {}; - css.walkRules(selector, rule => { rule.walkDecls(decl => { values[decl.prop] = decl.value; }); }); - return values; - }; - expect(declarations('p.publishError')).toMatchObject({ margin: '0', padding: '0', display: 'flex', 'align-items': 'flex-start', gap: '6px', color: '#C94E4E', 'font-size': '12px', 'line-height': '18px' }); - expect(declarations('.publishError > svg')).toMatchObject({ flex: 'none', 'margin-top': '2px' }); - }); }); diff --git a/apps/web/tests/components/share/ShareTab.stopped.test.tsx b/apps/web/tests/components/share/ShareTab.stopped.test.tsx index 8384916e65..9e8f6a6f7c 100644 --- a/apps/web/tests/components/share/ShareTab.stopped.test.tsx +++ b/apps/web/tests/components/share/ShareTab.stopped.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { buildWorkspacePermissions, buildWorkspaceSeatSummary, type WorkspaceCollabContext } from '@open-design/contracts'; import { FileViewer } from '../../../src/components/FileViewer'; @@ -26,12 +26,12 @@ beforeEach(() => { }); vi.stubGlobal('fetch', fetchMock); }); -afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); -async function openShare(workspaceContext: WorkspaceCollabContext | null = context) { +afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); }); +async function openShare(workspaceContext: WorkspaceCollabContext | null = context, streaming = false) { function Host() { const defaults = useProjectCollabContext(); return - + ; } render(); @@ -42,6 +42,7 @@ it('personal stopped history renders both clauses in the real host without any m expect(await screen.findByText(personalCopy)).toBeVisible(); expect(fetchMock).toHaveBeenCalledWith('/api/projects/project/share-state', expect.objectContaining({ headers: expect.objectContaining({ 'x-od-workspace-id': 'ws' }) })); expect(fetchMock.mock.calls.some(([url, options]) => String(url).includes('publish-public') && options?.method === 'POST')).toBe(false); + expect(screen.queryByRole('menuitem', { name: /generate and copy link/i })).toBeNull(); }); it('team stopped history omits the personal comment-sync claim', async () => { await openShare({ ...context, workspaceType: 'team', teamId: 'team' }); @@ -77,3 +78,164 @@ it('authoritative recovery clears a stopped notice on focus without polling or r expect(screen.queryByText(personalCopy)).toBeNull(); expect(fetchMock.mock.calls.some(([url, options]) => String(url).includes('publish-public') && options?.method === 'POST')).toBe(false); }); + +const publicUrl = 'https://viewer.example.test/cloud/artifact/project/stable'; +const currentPublication = { url: publicUrl, slug: 'stable', fileName: 'page.html' }; +function deferredResponse() { + let resolve!: (value: Response) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +function publicationRequests(method: 'POST' | 'DELETE') { + return fetchMock.mock.calls.filter(([url, options]) => String(url).includes('publish-public') && options?.method === method); +} + +it.each([false, true])('S9: the access switch stops one active link even while streaming=%s, preserves it until DELETE commits, and renders disabled state', async streaming => { + publications = [{ sourceFilePath: 'page.html', slug: 'stable', status: 'active' }]; + const pending = deferredResponse(); + fetchMock.mockImplementation(async (input, options) => { + const url = String(input); + if (url.endsWith('/share-state')) return Response.json({ projectId: 'project', bindingExists: true, hasEverShared: true, publications }); + if (url.includes('publish-public')) { + if (options?.method === 'DELETE') return pending.promise; + return Response.json({ publication: currentPublication }); + } + return Response.json({ deployments: [] }); + }); + await openShare(context, streaming); + const toggle = await screen.findByRole('switch', { name: /link access/i }); + await screen.findByText(publicUrl); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(toggle).toBeEnabled(); + // The design has one stop control, not a duplicate action beside Copy link. + expect(screen.queryByRole('button', { name: /stop sharing/i })).toBeNull(); + fireEvent.click(toggle); + await waitFor(() => expect(publicationRequests('DELETE')).toHaveLength(1)); + expect(JSON.parse(String(publicationRequests('DELETE')[0]?.[1]?.body))).toEqual({ slug: 'stable' }); + expect(toggle).toBeDisabled(); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByText(publicUrl)).toBeTruthy(); + fireEvent.click(toggle); + expect(publicationRequests('DELETE')).toHaveLength(1); + publications = [{ sourceFilePath: 'page.html', slug: 'stable', status: 'stopped' }]; + await act(async () => pending.resolve(Response.json({ ok: true, slug: 'stable', fileName: 'page.html' }))); + await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'false')); + expect(screen.queryByText(publicUrl)).toBeNull(); + expect(screen.queryByRole('button', { name: /copy share link/i })).toBeNull(); + expect(await screen.findByText(personalCopy)).toBeTruthy(); + expect(publicationRequests('DELETE')).toHaveLength(1); +}); + +it('S9-R: a stopped-link switch requests resume-only, locks while pending, then restores the original link', async () => { + const pending = deferredResponse(); + fetchMock.mockImplementation(async (input, options) => { + const url = String(input); + if (url.endsWith('/share-state')) return Response.json({ projectId: 'project', bindingExists: true, hasEverShared: true, publications }); + if (url.includes('publish-public')) { + if (options?.method === 'POST') return pending.promise; + return Response.json({ publication: null }); + } + return Response.json({ deployments: [] }); + }); + await openShare(); + await screen.findByText(personalCopy); + const toggle = await screen.findByRole('switch', { name: /link access/i }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + fireEvent.click(toggle); + await waitFor(() => expect(publicationRequests('POST')).toHaveLength(1)); + expect(JSON.parse(String(publicationRequests('POST')[0]?.[1]?.body))).toEqual({ mode: 'resume' }); + expect(toggle).toBeDisabled(); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByRole('menuitem', { name: '正在开启…' })).toBeDisabled(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + fireEvent.click(toggle); + expect(publicationRequests('POST')).toHaveLength(1); + publications = [{ sourceFilePath: 'page.html', slug: 'stable', status: 'active' }]; + await act(async () => pending.resolve(Response.json({ url: publicUrl, slug: 'stable', fileName: 'page.html' }))); + expect(await screen.findByText(publicUrl)).toBeVisible(); + expect(toggle).toHaveAttribute('aria-checked', 'true'); +}); + +it('S9-R clears a last-known URL after authoritative remote stop and offers resume-only instead of Stop', async () => { + publications = [{ sourceFilePath: 'page.html', slug: 'stable', status: 'active' }]; + let remoteStopped = false; + fetchMock.mockImplementation(async (input, options) => { + const url = String(input); + if (url.endsWith('/share-state')) return Response.json({ projectId: 'project', bindingExists: true, hasEverShared: true, publications }); + if (url.includes('publish-public')) { + if (options?.method === 'POST') return Response.json({ url: publicUrl, slug: 'stable', fileName: 'page.html' }); + return Response.json({ publication: remoteStopped ? null : currentPublication, status: remoteStopped ? 'stopped' : 'active' }); + } + return Response.json({ deployments: [] }); + }); + await openShare(); + const toggle = await screen.findByRole('switch', { name: /link access/i }); + await screen.findByText(publicUrl); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + remoteStopped = true; + publications = [{ sourceFilePath: 'page.html', slug: 'stable', status: 'stopped' }]; + fireEvent.click(await screen.findByRole('button', { name: /^Share$/ })); + await screen.findByText(personalCopy); + const reopenedToggle = await screen.findByRole('switch', { name: /link access/i }); + await waitFor(() => expect(reopenedToggle).toHaveAttribute('aria-checked', 'false')); + expect(screen.queryByText(publicUrl)).toBeNull(); + fireEvent.click(reopenedToggle); + await waitFor(() => expect(publicationRequests('POST')).toHaveLength(1)); + expect(JSON.parse(String(publicationRequests('POST')[0]?.[1]?.body))).toEqual({ mode: 'resume' }); + expect(publicationRequests('DELETE')).toHaveLength(0); +}); + +it('S10: rejected stop restores the checked switch, keeps the old link, and allows deliberate retry only', async () => { + publications = [{ sourceFilePath: 'page.html', slug: 'stable', status: 'active' }]; + const pending = deferredResponse(); + fetchMock.mockImplementation(async (input, options) => { + const url = String(input); + if (url.endsWith('/share-state')) return Response.json({ projectId: 'project', bindingExists: true, hasEverShared: true, publications }); + if (url.includes('publish-public')) { + if (options?.method === 'DELETE') return pending.promise; + return Response.json({ publication: currentPublication }); + } + return Response.json({ deployments: [] }); + }); + await openShare(); + const toggle = await screen.findByRole('switch', { name: /link access/i }); + await screen.findByText(publicUrl); + fireEvent.click(toggle); + expect(toggle).toBeDisabled(); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + await act(async () => pending.resolve(Response.json({ error: { message: 'no access' } }, { status: 503 }))); + expect(await screen.findByText(/could not turn off the link/i)).toBeTruthy(); + expect(toggle).toBeEnabled(); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByText(publicUrl)).toBeTruthy(); + expect(publicationRequests('DELETE')).toHaveLength(1); + fireEvent.click(toggle); + await waitFor(() => expect(publicationRequests('DELETE')).toHaveLength(2)); +}); + +it('S10: rejected open restores the unchecked switch and offers a manual retry without auto-republishing', async () => { + const pending = deferredResponse(); + fetchMock.mockImplementation(async (input, options) => { + const url = String(input); + if (url.endsWith('/share-state')) return Response.json({ projectId: 'project', bindingExists: true, hasEverShared: true, publications }); + if (url.includes('publish-public')) { + if (options?.method === 'POST') return pending.promise; + return Response.json({ publication: null }); + } + return Response.json({ deployments: [] }); + }); + await openShare(); + const toggle = await screen.findByRole('switch', { name: /link access/i }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + fireEvent.click(toggle); + await waitFor(() => expect(publicationRequests('POST')).toHaveLength(1)); + expect(toggle).toBeDisabled(); + await act(async () => pending.resolve(Response.json({ error: 'temporarily_unavailable' }, { status: 503 }))); + expect(await screen.findByText(/could not create the share link/i)).toBeTruthy(); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(toggle).toBeEnabled(); + expect(publicationRequests('POST')).toHaveLength(1); + fireEvent.click(toggle); + await waitFor(() => expect(publicationRequests('POST')).toHaveLength(2)); +}); diff --git a/apps/web/tests/components/share/useCommentSyncState.test.tsx b/apps/web/tests/components/share/useCommentSyncState.test.tsx index ead2daee98..dc37ce9c55 100644 --- a/apps/web/tests/components/share/useCommentSyncState.test.tsx +++ b/apps/web/tests/components/share/useCommentSyncState.test.tsx @@ -48,6 +48,22 @@ describe('production comment sync state reader', () => { await waitFor(() => expect(result.current).toEqual(recovered)); expect(fetchMock).toHaveBeenCalledTimes(2); }); + it('keeps same-scope observation during a focus refresh and applies its result', async () => { + const current = { ...paused, sessionMissing: false, pending: 0, + backfill: { state: 'failed' as const, filePath: 'index.html', publicationRevision: 'r1', retryable: true } }; + fetchMock.mockResolvedValueOnce(Response.json(current)); + let finish!: (response: Response) => void; + fetchMock.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const { result } = renderHook(() => useCommentSyncState('p', context, { filePath: 'index.html' })); + await waitFor(() => expect(result.current).toEqual(current)); + act(() => window.dispatchEvent(new Event('focus'))); + expect(result.current).toEqual(current); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + const completed = { ...current, backfill: { ...current.backfill, state: 'succeeded' as const, retryable: false } }; + await act(async () => { finish(Response.json(completed)); }); + expect(result.current).toEqual(completed); + }); + it('fences late old-account responses even if project and membership stay equal', async () => { let finish!: (response: Response) => void; fetchMock.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); diff --git a/apps/web/tests/fixtures/share-tab/share-panel.personal.html b/apps/web/tests/fixtures/share-tab/share-panel.personal.html index e69de29bb2..1be5998292 100644 --- a/apps/web/tests/fixtures/share-tab/share-panel.personal.html +++ b/apps/web/tests/fixtures/share-tab/share-panel.personal.html @@ -0,0 +1 @@ +
Link access

A share link needs a signed-in workspace. Sign in to OpenDesign Cloud, or use a deploy option to share this file.

\ No newline at end of file diff --git a/apps/web/tests/fixtures/share-tab/share-panel.team.html b/apps/web/tests/fixtures/share-tab/share-panel.team.html index c3e562f4f4..680fb7d783 100644 --- a/apps/web/tests/fixtures/share-tab/share-panel.team.html +++ b/apps/web/tests/fixtures/share-tab/share-panel.team.html @@ -1 +1 @@ -
Link access

Recipients can view the preview and existing comments — not the conversation or code.

Only you can access this project. Choose workspace members to share it with the team.

\ No newline at end of file +
Link access

Recipients can view the preview and existing comments — not the conversation or code.

Only you can access this project. Choose workspace members to share it with the team.

\ No newline at end of file diff --git a/apps/web/tests/i18n/share-failure-copy.test.ts b/apps/web/tests/i18n/share-failure-copy.test.ts index 5703fc4af4..26e7769818 100644 --- a/apps/web/tests/i18n/share-failure-copy.test.ts +++ b/apps/web/tests/i18n/share-failure-copy.test.ts @@ -21,12 +21,12 @@ const expected: Record = { "th": "สร้างลิงก์แชร์ไม่สำเร็จ โปรดลองอีกครั้งในภายหลัง", "tr": "Paylaşım bağlantısı oluşturulamadı. Lütfen daha sonra tekrar deneyin.", "uk": "Не вдалося створити посилання. Повторіть спробу пізніше.", - "zh-CN": "生成分享链接失败,请稍后重试。", + "zh-CN": "上传失败,请检查网络后重试。", "zh-TW": "產生分享連結失敗,請稍後重試。" }; const directory = resolve(__dirname, '../../src/i18n/locales'); -describe('share failure recovery copy', () => { +describe('Owner S7 publish failure copy', () => { it('covers every shipped locale', () => { expect(readdirSync(directory).filter(name => name.endsWith('.ts')).map(name => name.slice(0, -3)).sort()).toEqual(Object.keys(expected).sort()); expect(Object.keys(expected)).toHaveLength(19); diff --git a/apps/web/tests/providers/registry.test.ts b/apps/web/tests/providers/registry.test.ts index bfe37f780a..a25b3eca46 100644 --- a/apps/web/tests/providers/registry.test.ts +++ b/apps/web/tests/providers/registry.test.ts @@ -1,3 +1,4 @@ +import { JSDOM } from 'jsdom'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { installMockOpenDesignHost } from '@open-design/host/testing'; import { advanceWorkspaceAccountGeneration } from '../../src/collab/workspace-identity'; @@ -1777,10 +1778,7 @@ describe('connectConnector', () => { it('renders a fallback link before navigating the auth popup', async () => { const replace = vi.fn(); const authWindow = { - document: { - title: '', - body: { innerHTML: '' }, - }, + document: new JSDOM('').window.document, location: { replace }, close: vi.fn(), }; @@ -1829,10 +1827,7 @@ describe('connectConnector', () => { it('keeps the popup open with custom auth guidance when initialization fails', async () => { const authWindow = { - document: { - title: '', - body: { innerHTML: '' }, - }, + document: new JSDOM('').window.document, location: { replace: vi.fn() }, close: vi.fn(), }; @@ -1907,10 +1902,7 @@ describe('connectConnector', () => { it('renders an info notice in the popup when the connect response carries no redirect URL', async () => { const authWindow = { - document: { - title: '', - body: { innerHTML: '' }, - }, + document: new JSDOM('').window.document, location: { replace: vi.fn() }, close: vi.fn(), }; diff --git a/apps/web/tests/team-projects-cache-key-collision.test.tsx b/apps/web/tests/team-projects-cache-key-collision.test.tsx index 13390e4db3..bf686c5fb4 100644 --- a/apps/web/tests/team-projects-cache-key-collision.test.tsx +++ b/apps/web/tests/team-projects-cache-key-collision.test.tsx @@ -117,6 +117,34 @@ describe('team-projects coalescing key is shape-safe across call sites', () => { await expect(projectIsSharedWithWorkspace('p-shared', TEAM_CONTEXT)).resolves.toBe(true); }); + it('coalesces same-event fresh scope reads and re-reads for a distinct rapid team event', async () => { + let statusReads = 0; + let catalogReads = 0; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/collab/status')) { + statusReads++; + return new Response('not found', { status: 404 }); + } + if (url.includes('/api/workspace/projects/team')) { + catalogReads++; + return Response.json({ projects: catalogReads === 1 ? [TEAM_PROJECT] : [] }); + } + throw new Error(`unexpected scope request ${url}`); + })); + const firstEvent = new Event('od:team-projects-changed'); + const [first, sibling] = await Promise.all([ + projectIsSharedWithWorkspace('p-shared', TEAM_CONTEXT, { event: firstEvent }), + projectIsSharedWithWorkspace('p-shared', TEAM_CONTEXT, { event: firstEvent }), + ]); + expect([first, sibling]).toEqual([true, true]); + expect(statusReads).toBe(1); + expect(catalogReads).toBe(1); + const secondEvent = new Event('od:team-projects-changed'); + await expect(projectIsSharedWithWorkspace('p-shared', TEAM_CONTEXT, { event: secondEvent })).resolves.toBe(false); + expect(statusReads).toBe(2); + expect(catalogReads).toBe(2); + }); }); describe('shared-project predicate never white-screens on a malformed catalog', () => { diff --git a/docs/screenshots/product-tour/share-popup-comment-sync-unboxed.png b/docs/screenshots/product-tour/share-popup-comment-sync-unboxed.png new file mode 100644 index 0000000000..edde3e2551 Binary files /dev/null and b/docs/screenshots/product-tour/share-popup-comment-sync-unboxed.png differ diff --git a/e2e/lib/playwright/ui-audit-boards.ts b/e2e/lib/playwright/ui-audit-boards.ts new file mode 100644 index 0000000000..5e32348a80 --- /dev/null +++ b/e2e/lib/playwright/ui-audit-boards.ts @@ -0,0 +1,131 @@ +/* Render screenshot-backed Current boards only; run from e2e: + * with-env pnpm exec tsx lib/playwright/ui-audit-boards.ts + * No browser/service is started. Never use design images as actual frames. + */ +import { readFile, writeFile, mkdir, copyFile, cp, readdir, unlink } from 'node:fs/promises'; +import { resolve } from 'node:path'; +const root = resolve(import.meta.dirname, '../../..'); +const audit = resolve(root, '.tmp/ui-audit'); +const boards = resolve(audit, 'output/boards'); +const captures = resolve(audit, 'output/captures/actual'); +const source = resolve(audit, 'captures/actual'); +const index = await readFile(resolve(audit, 'inventory.md'), 'utf8'); +const section = index.split('## 当前有效状态')[1]?.split('## 作废')[0] ?? ''; +const states = section.split('\n').filter(line => /^\| [A-Z][A-Z0-9-]* \|/.test(line)).map(line => { + const [id, name, side, design, entry] = line.split('|').slice(1).map(cell => cell.trim()); + return { id: id ?? '', name: name ?? '', side: side ?? '', design: design ?? '', entry: entry ?? '' }; +}); +const names = ['map','publish','open','comment','owner','update','dialogs']; +const groups = ['Chain-0','Chain-1','Chain-2','Chain-3','Chain-4','Chain-5','Chain-6']; +const escape = (s: string) => s.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"'); +const available = new Map(); +await mkdir(boards, { recursive: true }); await mkdir(captures, { recursive: true }); +for (const state of states) { + try { + const bytes = await readFile(resolve(source, `${state.id}.png`)); + if (bytes.length < 100 || bytes.subarray(0,8).toString('hex') !== '89504e470d0a1a0a') continue; + const data = JSON.parse(await readFile(resolve(source, `${state.id}.json`), 'utf8')) as Record; + if (data.id !== state.id || !data.source || !['actual-ui','actual-mock','actual-real'].includes(String(data.source))) continue; + // Error-code E2/E3 inversion is the audited discrepancy itself. For every + // other state, an observed fallback screen is evidence of unreachability, + // not a valid visual frame of the requested state. + if (data.observedState !== state.id && !['E2','E3'].includes(state.id)) continue; + const elements = Array.isArray(data.elements) ? data.elements as Array> : []; + const styles = elements.slice(0,6).map(el => { + const s = (el.style ?? {}) as Record; + return `${String(el.tag ?? '')} ${String(el.text ?? '').slice(0,20)}: ${s.color ?? ''} ${s.fontSize ?? ''} ${s.width ?? ''}×${s.height ?? ''} r${s.borderRadius ?? ''}`; + }).join(';'); + await copyFile(resolve(source, `${state.id}.png`), resolve(captures, `${state.id}.png`)); + available.set(state.id, { screenshot: `../captures/actual/${state.id}.png`, provenance: String(data.source), styles }); + } catch { /* Missing or unverifiable actual capture is excluded, never synthesized. */ } +} +// A prior accepted frame can later be disproved (C2 hover is one example). +// Remove only stale copied state PNGs; retain the original screenshot as issue evidence. +// Cross-end states require two independently captured Playwright frames. A lone +// OD or Vela image never certifies a cross-end state; both remain mock evidence. +for (const id of ['O1','O2','O3','O5','O6','O7','O8']) { + try { + let styles = 'OD / vela 双端独立截图;见各自 JSON'; + const halves = await Promise.all((['OD','vela'] as const).map(async side => { + const half = `${id}-${side}`; + const bytes = await readFile(resolve(source, `${half}.png`)); + const data = JSON.parse(await readFile(resolve(source, `${half}.json`), 'utf8')) as Record; + if (bytes.length < 100 || bytes.subarray(0,8).toString('hex') !== '89504e470d0a1a0a' + || data.id !== half || data.observedState !== half || data.source !== 'actual-mock' + || typeof data.text !== 'string' || !data.text.trim()) throw new Error(`Invalid cross-end half: ${half}`); + return { half, data }; + })); + if (id === 'O1') { + const od = halves[0]!.data, vela = halves[1]!.data; + if (od.mockWorkspaceType !== 'team' || typeof od.authorKey !== 'string' || !/^[a-f0-9]{64}$/.test(od.authorKey) + || od.authorKey !== vela.authorKey || vela.memberId !== 'mem-other-team-member' + || !String(od.text).includes('来自团队成员的评论') || !String(vela.text).includes('来自团队成员的评论')) throw new Error('O1 member author halves do not match'); + styles = '团队身份与作者键双端一致;两端均为独立 GET 假数据,非真实投递'; + } + if (id === 'O5') { + const od = halves[0]!.data, vela = halves[1]!.data; + const odColors = od.avatarColors, velaColors = vela.avatarColors; + if (typeof od.authorKey !== 'string' || !/^[a-f0-9]{64}$/.test(od.authorKey) || od.authorKey !== vela.authorKey + || !Array.isArray(odColors) || odColors.length !== 2 || odColors[0] !== odColors[1] + || !Array.isArray(velaColors) || velaColors.length !== 2 || velaColors[0] !== velaColors[1] + || odColors[0] !== velaColors[0] || vela.pinColor !== velaColors[0] + || !Array.isArray(od.pinColors) || od.pinColors.length !== 2 + || od.pinColors.some(color => color !== 'rgb(217, 106, 70)')) throw new Error('O5 author/pin colors did not match'); + styles = `同键头像 ${odColors[0]};OD 图钉 ${od.pinColors[0]};分享页图钉 ${vela.pinColor}`; + } + if (id === 'O6') { + const od = halves[0]!.data, vela = halves[1]!.data; + if (typeof od.ownerComment !== 'string' || od.ownerComment !== vela.ownerComment + || !String(od.text).includes(od.ownerComment) || !String(vela.text).includes(od.ownerComment) + || vela.isMine !== true || typeof od.mockProvenance !== 'string' + || typeof vela.mockProvenance !== 'string') throw new Error('O6 static owner comment witnesses differ'); + styles = 'Owner 本地/分享页双端各自 GET 模拟;未验证分享页发出→客户端回流'; + } + if (id === 'O7') { + const od = halves[0]!.data, vela = halves[1]!.data; + if (typeof od.frozenNow !== 'string' || od.frozenNow !== vela.frozenNow + || typeof od.commentCreatedAt !== 'string' || od.commentCreatedAt !== vela.commentCreatedAt + || od.displayedTime !== '昨天' || vela.displayedTime !== '2 小时前') throw new Error('O7 frozen-time witnesses differ'); + styles = `同一时间与评论:OD ${od.displayedTime};vela ${vela.displayedTime}(UI-029)`; + } + if (id === 'O8') { + const od = halves[0]!.data, vela = halves[1]!.data; + if (!Array.isArray(od.keys) || od.keys.length !== 30 || od.keys.some(key => typeof key !== 'string' || !/^[a-f0-9]{64}$/.test(key)) + || JSON.stringify(od.keys) !== JSON.stringify(vela.keys) + || !Array.isArray(od.palette) || od.palette.length !== 30 + || !Array.isArray(vela.palette) || vela.palette.length !== 30 + || new Set(od.palette.map(item => item.color)).size !== 30 + || JSON.stringify(od.palette) !== JSON.stringify(vela.palette) + || od.apiInputOrder !== 'ascending' || vela.apiInputOrder !== 'descending' + || JSON.stringify(od.displayedIndexes) !== JSON.stringify(Array.from({length:30},(_,index)=>29-index)) + || JSON.stringify(vela.displayedIndexes) !== JSON.stringify(od.displayedIndexes)) throw new Error('O8 30-color parity or input/display order witnesses differ'); + styles = 'API 输入顺序相反;双端 UI 都显示 29→0;逐 authorKey 对应 30 个一致且互异的实际 CSS 色值'; + } + await Promise.all(halves.map(({half}) => copyFile(resolve(source, `${half}.png`), resolve(captures, `${half}.png`)))); + available.set(id, { screenshot: `../captures/actual/${halves[0]!.half}.png`, secondaryScreenshot: `../captures/actual/${halves[1]!.half}.png`, provenance: 'paired-mocks', styles }); + } catch (error) { console.warn(`${id}: paired screenshot excluded: ${String(error)}`); } +} +const knownIds = new Set(states.map(state => state.id)); +for (const name of await readdir(captures)) { + const id = name.endsWith('.png') ? name.slice(0, -4) : ''; + const pair = id.match(/^(O1|O2|O3|O5|O6|O7|O8)-(?:OD|vela)$/)?.[1]; + if ((knownIds.has(id) && !available.has(id)) || (pair && !available.has(pair))) await unlink(resolve(captures, name)); +} +const head = await readFile(resolve(audit, 'input/board-head.tpl'), 'utf8'); +await copyFile(resolve(audit, 'input/boards/Chain-1-publish/support.js'), resolve(boards, 'support.js')); +await cp(resolve(audit, 'input/boards/Chain-1-publish/vendor'), resolve(boards, 'vendor'), { recursive:true }); +for (let i=0;i s.design.startsWith(groups[i] ?? 'NO MATCH') || (i===4 && s.design.startsWith('研发补(Chain-4')) || (i===2 && s.design.startsWith('研发补(Chain-2')) || (i===3 && s.design.startsWith('研发补(Chain-3')) || (i===5 && s.design.startsWith('研发补(Chain-5')) || (i===6 && s.design.startsWith('研发补(Chain-6')) || (i===1 && s.design.startsWith('研发补(Chain-1'))); + const present = group.filter(s => available.has(s.id)); + if (i !== 0 && !present.length) continue; + const cards = i === 0 ? group.map(s => `${escape(s.id)}${escape(s.name)}${escape(s.side)}${available.has(s.id) ? `现状帧` : '—'}`).join('') : present.map(s => { + const a = available.get(s.id)!; + const paired = a.secondaryScreenshot ? `${escape(s.name)} · vela Playwright 实际截图` : ''; + return `
${escape(s.id)} ${escape(s.name)} ${escape(s.side)}
${escape(s.name)} · ${paired ? 'OD ' : ''}Playwright 实际截图${paired}

进入条件:${escape(s.entry)}

数据来源:${a.provenance === 'paired-mocks' ? 'OD / vela 各自接口假数据;非同一生产事件' : a.provenance === 'actual-mock' ? '接口假数据' : '真实流程'}

关键样式:${escape(a.styles || '样式 JSON 待补齐')}

`; + }).join(''); + const body = i === 0 ? `${cards}
编号名称端现状画板
` : `
${cards}
`; + const html = `${head}\n\n

实际 UI · Playwright 截图

Current-${i} · ${names[i]}

${body}
`; + await writeFile(resolve(boards, `Current-${i}-${names[i]}.dc.html`), html); +} +function groupIndex(design:string):number {const match=design.match(/Chain-(\d)/); return match ? Math.min(6, Number(match[1])) : 4;} +console.log(JSON.stringify({ actualFrames: available.size, boards: (await readdir(boards)).filter(x=>x.endsWith('.dc.html')) })); diff --git a/e2e/lib/playwright/ui-audit-design.ts b/e2e/lib/playwright/ui-audit-design.ts new file mode 100644 index 0000000000..3d7c587edd --- /dev/null +++ b/e2e/lib/playwright/ui-audit-design.ts @@ -0,0 +1,106 @@ +/* Local UI-audit design reference capture. Run from e2e with: + * with-env pnpm exec tsx lib/playwright/ui-audit-design.ts [Chain-1-publish] + * Never start while another browser runtime is active. + */ +import { chromium } from '@playwright/test'; +import { createServer } from 'node:http'; +import { readFile, mkdir, writeFile, open, unlink, rename } from 'node:fs/promises'; +import { resolve, relative, extname, sep } from 'node:path'; +import { once } from 'node:events'; +import { execFileSync } from 'node:child_process'; + +const repo = resolve(import.meta.dirname, '../../..'); +const input = resolve(repo, '.tmp/ui-audit/input/boards'); +const output = resolve(repo, '.tmp/ui-audit/captures/design'); +const boardNames = ['Chain-1-publish', 'Chain-2-open', 'Chain-3-comment', 'Chain-4-owner', 'Chain-5-update', 'Chain-6-dialogs']; +const requested = process.argv[2] ? [process.argv[2]] : boardNames; +const memory = execFileSync('memory_pressure', ['-Q'], { encoding: 'utf8' }); +const free = Number(memory.match(/System-wide memory free percentage: (\d+)%/)?.[1] ?? 0); +if (free < 20) throw new Error(`Insufficient free memory for capture: ${free}%`); +let activeBrowsers = ''; +try { activeBrowsers = execFileSync('pgrep', ['-fl', 'chrome-headless-shell|chromium.*--headless|Google Chrome for Testing.*--headless'], { encoding: 'utf8' }); } catch { /* No matching browser runtime. */ } +if (activeBrowsers.trim()) throw new Error('Another browser runtime is active; capture deferred (do not stop it).'); +if (requested.some((name) => !boardNames.includes(name))) throw new Error('Unknown board'); +const inventory = await readFile(resolve(repo, '.tmp/ui-audit/inventory.md'), 'utf8'); +const targets = inventory.split('\n').filter((row) => /^\| [A-Z][A-Z0-9-]* \|/.test(row)).map((row) => { + const cells = row.split('|').slice(1, -1).map((cell) => cell.trim()); + return { id: cells[0] ?? '', frame: cells[3] ?? '' }; +}).filter((row) => row.id !== '' && row.frame.startsWith('Chain-')); +await mkdir(output, { recursive: true }); +const server = createServer(async (req, res) => { + const path = resolve(input, '.' + decodeURIComponent(new URL(req.url ?? '/', 'http://localhost').pathname)); + if (path !== input && !path.startsWith(input + sep)) { res.writeHead(403).end(); return; } + try { + const bytes = await readFile(path); + res.setHeader('Content-Type', extname(path) === '.html' ? 'text/html; charset=utf-8' : extname(path) === '.js' ? 'text/javascript; charset=utf-8' : 'application/octet-stream'); + res.end(bytes); + } catch { res.writeHead(404).end(); } +}); +server.listen(0, '127.0.0.1'); +await once(server, 'listening'); +const address = server.address(); +if (!address || typeof address === 'string') throw new Error('No local HTTP port'); +let browser: Awaited> | undefined; +const reportPath = resolve(output, 'capture-report.json'); +const report: Array<{ id: string; board: string; outcome: string }> = []; +try { + const prior = JSON.parse(await readFile(reportPath, 'utf8')) as typeof report; + if (Array.isArray(prior)) report.push(...prior); +} catch { /* First capture has no report yet. */ } +const lockPath = resolve(repo, '.tmp/ui-audit/design-capture.lock'); +let lock: Awaited>; +try { lock = await open(lockPath, 'wx'); } catch (error) { server.close(); throw error; } +try { + // The lock coordinates our own capture runs; process preflight also guards foreign browsers. + let competing = ''; + try { competing = execFileSync('pgrep', ['-fl', 'chrome-headless-shell|chromium.*--headless|Google Chrome for Testing.*--headless'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } catch { /* No competing process. */ } + if (competing.trim()) throw new Error('Another browser runtime started after preflight; capture deferred.'); + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, colorScheme: 'light', locale: 'zh-CN' }); + try { + const page = await context.newPage(); + for (const board of requested) { + await page.goto(`http://127.0.0.1:${address.port}/${board}/${board}.dc.html`, { waitUntil: 'networkidle' }); + await page.locator('.board').first().waitFor({ timeout: 15000 }); + for (const row of targets.filter((item) => item.frame.startsWith(board + ' '))) { + const { id } = row; + if (id === 'G4') { report.push({ id, board, outcome: 'shared-frame-negative-assertion' }); continue; } + const locator = page.locator('.sid').filter({ hasText: new RegExp(`^${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) }).first(); + if (await locator.count() === 0) { report.push({ id, board, outcome: 'missing-id' }); continue; } + const target = locator.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " cell ")][1]'); + const hasMockup = await target.evaluate((node) => Array.from(node.children).some((child) => !child.classList.contains('meta') && child.getBoundingClientRect().height > 20)).catch(() => false); + if (!hasMockup) { report.push({ id, board, outcome: 'missing-mockup' }); continue; } + const box = await target.boundingBox(); + if (!box || box.width < 20 || box.height < 20) { report.push({ id, board, outcome: 'missing-frame' }); continue; } + const screenshot = resolve(output, `${id}.png`); + try { + const previous = JSON.parse(await readFile(resolve(output, `${id}.json`), 'utf8')) as { board?: string }; + if (previous.board && previous.board !== board) { report.push({ id, board, outcome: `duplicate-source:${previous.board}` }); continue; } + } catch { /* No previous capture metadata. */ } + const data = await target.evaluate((node) => { + const nodes = [node, ...Array.from(node.querySelectorAll('*')).slice(0, 120)]; + return { text: (node.textContent || '').trim(), elements: nodes.map((element, index) => { + const style = getComputedStyle(element); + const bounds = element.getBoundingClientRect(); + return { index, tag: element.tagName.toLowerCase(), text: (element.textContent || '').trim().slice(0, 160), style: { color: style.color, backgroundColor: style.backgroundColor, fontSize: style.fontSize, fontWeight: style.fontWeight, lineHeight: style.lineHeight, borderRadius: style.borderRadius, boxShadow: style.boxShadow, width: bounds.width, height: bounds.height, margin: style.margin, padding: style.padding } }; + }) }; + }); + await target.screenshot({ path: screenshot, animations: 'disabled' }); + await writeFile(resolve(output, `${id}.json`), JSON.stringify({ id, board, frame: row.frame, source: 'design-board', viewport: '1440x900@2', screenshot: relative(repo, screenshot), ...data }, null, 2)); + const previousIndex = report.findIndex((entry) => entry.id === id && entry.board === board); + if (previousIndex >= 0) report.splice(previousIndex, 1); + report.push({ id, board, outcome: 'captured' }); + } + } + } finally { await context.close(); } +} finally { + await browser?.close(); + server.close(); + await once(server, 'close'); + try { + const nextReport = `${reportPath}.next`; + await writeFile(nextReport, JSON.stringify(report, null, 2)); + await rename(nextReport, reportPath); + } finally { await lock.close(); await unlink(lockPath); } +} +console.log(JSON.stringify({ captured: report.filter((r) => r.outcome === 'captured').length, missing: report.filter((r) => r.outcome !== 'captured'), boards: requested })); diff --git a/e2e/lib/playwright/ui-audit-map.ts b/e2e/lib/playwright/ui-audit-map.ts new file mode 100644 index 0000000000..c07e9cfc09 --- /dev/null +++ b/e2e/lib/playwright/ui-audit-map.ts @@ -0,0 +1,18 @@ +/* Draft element maps from verified screenshot JSON; no inferred UI equivalence. */ +import { readFile,writeFile,mkdir,readdir } from 'node:fs/promises'; +import {resolve} from 'node:path'; +const root=resolve(import.meta.dirname,'../../..');const base=resolve(root,'.tmp/ui-audit/captures');const out=resolve(base,'map');await mkdir(out,{recursive:true}); +const inventory=await readFile(resolve(root,'.tmp/ui-audit/inventory.md'),'utf8'); +const valid=new Set(Array.from(inventory.matchAll(/^\| ([A-Z][A-Z0-9-]*) \|/gm),m=>m[1])); +const actual=(await readdir(resolve(base,'actual'))).filter(x=>x.endsWith('.json')&&valid.has(x.slice(0,-5))); +for(const file of actual){const id=file.slice(0,-5);let a:{id:string;elements?:Array<{index:number;tag:string;text:string;style:Record}>;observedState?:string}; +try { a=JSON.parse(await readFile(resolve(base,'actual',file),'utf8')) as typeof a; } catch(error) { throw new Error(`Invalid actual capture JSON: ${file}`,{cause:error}); } +if(a.id!==id)throw new Error(`Capture ID mismatch: ${file}`);let d:typeof a|undefined; +try { d=JSON.parse(await readFile(resolve(base,'design',file),'utf8')) as typeof a; } catch(error) { + if (!(error instanceof Error && 'code' in error && error.code==='ENOENT')) throw new Error(`Invalid design capture JSON: ${file}`,{cause:error}); +} +const leaves=(list:typeof a.elements)=>list?.filter(x=>x.text.trim().length>0&&x.text.trim().length<=60&&!['div','main','body','article'].includes(x.tag))??[]; +const failedState=a.observedState!==id&&!['E2','E3'].includes(id); +const matches=[];const used=new Set();for(const source of failedState?[]:leaves(d?.elements)){const target=leaves(a.elements).find(x=>x.text.trim()===source.text.trim()&&!used.has(x.index));if(!target)continue;used.add(target.index);matches.push({design:{index:source.index,tag:source.tag,text:source.text,style:source.style},actual:{index:target.index,tag:target.tag,text:target.text,style:target.style},matchBasis:'exact visible text; element identity still needs reviewer confirmation'});} +await writeFile(resolve(out,file),JSON.stringify({id,source:{design:d?`../design/${file}`:null,actual:`../actual/${file}`},observedState:a.observedState??null,status:failedState?'unreachable-observed-fallback':d?'draft-exact-text-only':'no-design-research-supplement',pairs:matches,warning:'Do not infer style parity from this draft; inspect matching role/structure/screenshot before classifying.'},null,2));} +console.log(JSON.stringify({draftMaps:actual.length})); diff --git a/e2e/lib/playwright/ui-audit-verify.ts b/e2e/lib/playwright/ui-audit-verify.ts new file mode 100644 index 0000000000..e1062246b2 --- /dev/null +++ b/e2e/lib/playwright/ui-audit-verify.ts @@ -0,0 +1,15 @@ +/* Check the portable Current boards in one headless browser; no app server. */ +import { chromium } from '@playwright/test'; +import { resolve } from 'node:path'; +import { readdir } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +const directory=resolve(import.meta.dirname,'../../../.tmp/ui-audit/output/boards'); +const files=(await readdir(directory)).filter(name=>/^Current-[0-6]-.*\.dc\.html$/.test(name)).sort(); +if(files.length!==7)throw new Error(`Expected 7 boards, found ${files.length}`); +const browser=await chromium.launch({headless:true}); +try{const page=await browser.newPage({viewport:{width:1600,height:900}});for(const file of files){ + await page.goto(pathToFileURL(resolve(directory,file)).href,{waitUntil:'load'}); + const check=await page.locator('.frame img').evaluateAll(images=>images.map(img=>({src:(img as HTMLImageElement).getAttribute('src'),ok:(img as HTMLImageElement).complete&&(img as HTMLImageElement).naturalWidth>0}))); + if(check.some(image=>!image.ok))throw new Error(`${file} has an unloaded image: ${JSON.stringify(check.filter(image=>!image.ok))}`); + console.log(`${file}: ${check.length} decoded screenshots`); +}}finally{await browser.close();} diff --git a/e2e/ui/ui-audit-od-f01.test.ts b/e2e/ui/ui-audit-od-f01.test.ts new file mode 100644 index 0000000000..134e816bbc --- /dev/null +++ b/e2e/ui/ui-audit-od-f01.test.ts @@ -0,0 +1,79 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { expect, test } from '@/playwright/suite'; +import { T } from '@/timeouts'; +import { mockAmrPersonalWorkspace, AMR_PERSONAL_WORKSPACE_CONTEXT } from '../lib/playwright/amr.js'; + +const output = resolve(import.meta.dirname, '../../.tmp/ui-audit/final-design-review/phase-B-f01'); +test.use({ viewport: { width: 1440, height: 904 }, deviceScaleFactor: 2, colorScheme: 'light', locale: 'zh-CN' }); + +test('F01 long comments fold independently in real OD file viewer', async ({ page }) => { + test.setTimeout(T.xlong); + await page.addInitScript(() => { + localStorage.setItem('open-design:locale', 'zh-CN'); + localStorage.setItem('open-design:locale-source', 'manual'); + }); + await mockAmrPersonalWorkspace(page); + await page.route('**/api/projects/*/workspace-scope', async route => { + const projectId = new URL(route.request().url()).pathname.match(/\/api\/projects\/([^/]+)/)?.[1]; + await route.fulfill({ json: { scope: { kind: 'personal', projectId, + workspaceId: AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId, + visibility: 'personal', context: AMR_PERSONAL_WORKSPACE_CONTEXT } } }); + }); + const projectId = `ui-audit-f01-${Date.now()}`; + const created = await page.request.post('/api/projects', { data: { + id: projectId, name: 'F01 comment folding fixture', skillId: null, designSystemId: null, + metadata: { kind: 'prototype', nameSource: 'user' }, + } }); + expect(created.ok(), await created.text()).toBe(true); + const { conversationId } = await created.json() as { conversationId: string }; + const content = '

审计产物

'; + const file = await page.request.post(`/api/projects/${projectId}/files`, { data: { + name: 'index.html', content, + artifactManifest: { version: 1, kind: 'html', title: 'index.html', entry: 'index.html', renderer: 'html', exports: ['html'] }, + } }); + expect(file.ok(), await file.text()).toBe(true); + const target = { filePath: 'index.html', elementId: 'audit-target', selector: '[data-od-id="audit-target"]', + label: '标题', text: '审计产物', htmlHint: '

', + position: { x: 0.1, y: 0.1, width: 0.5, height: 0.1 } }; + const notes = [ + Array.from({ length: 7 }, (_, index) => `第一条很长的评论 第${index + 1}行需要独立展开。`).join('\n'), + '短评论。', + Array.from({ length: 6 }, (_, index) => `第二条很长的评论 第${index + 1}行不会跟着展开。`).join('\n'), + ]; + for (const note of notes) { + const saved = await page.request.post(`/api/projects/${projectId}/conversations/${conversationId}/comments`, { + data: { target, note }, + }); + expect(saved.ok(), await saved.text()).toBe(true); + } + await page.goto(`/projects/${projectId}/files/index.html`); + await page.getByTestId('comment-panel-toggle').click(); + const panel = page.getByTestId('comment-side-panel'); + await expect(panel).toBeVisible(); + const first = panel.getByTestId('comment-side-item').filter({ hasText: '第一条很长的评论' }); + const brief = panel.getByTestId('comment-side-item').filter({ hasText: '短评论。' }); + const second = panel.getByTestId('comment-side-item').filter({ hasText: '第二条很长的评论' }); + await expect(panel.getByTestId('comment-side-item')).toHaveCount(3); + const expandFirst = first.getByRole('button', { name: '展开', exact: true }); + await expect(expandFirst).toHaveAttribute('aria-expanded', 'false'); + await expect(second.getByRole('button', { name: '展开', exact: true })).toBeVisible(); + await expect(brief.getByRole('button', { name: '展开', exact: true })).toHaveCount(0); + const folded = await first.locator('.comment-side-body').evaluate(element => ({ + scrollHeight: element.scrollHeight, clientHeight: element.clientHeight, + lineHeight: getComputedStyle(element).lineHeight, + width: element.getBoundingClientRect().width, + })); + expect(folded.scrollHeight).toBeGreaterThan(folded.clientHeight); + await mkdir(output, { recursive: true }); + await page.screenshot({ path: resolve(output, 'OD-F01-folded.png'), animations: 'disabled' }); + await expandFirst.click(); + await expect(first.getByRole('button', { name: '收起', exact: true })).toHaveAttribute('aria-expanded', 'true'); + await expect(second.getByRole('button', { name: '展开', exact: true })).toHaveAttribute('aria-expanded', 'false'); + await page.screenshot({ path: resolve(output, 'OD-F01-first-expanded.png'), animations: 'disabled' }); + await first.getByRole('button', { name: '收起', exact: true }).click(); + await expect(first.getByRole('button', { name: '展开', exact: true })).toHaveAttribute('aria-expanded', 'false'); + await page.screenshot({ path: resolve(output, 'OD-F01-restored.png'), animations: 'disabled' }); + await writeFile(resolve(output, 'metrics.json'), JSON.stringify({ viewport: page.viewportSize(), folded, + capture: ['folded', 'first-expanded', 'restored'], source: 'actual-real-local-OD' }, null, 2)); +}); diff --git a/e2e/ui/ui-audit-od-share-k-sequence.test.ts b/e2e/ui/ui-audit-od-share-k-sequence.test.ts new file mode 100644 index 0000000000..774020fe18 --- /dev/null +++ b/e2e/ui/ui-audit-od-share-k-sequence.test.ts @@ -0,0 +1,214 @@ +import { expect, test } from '@/playwright/suite'; +import { mockAmrPersonalWorkspace, AMR_PERSONAL_WORKSPACE_CONTEXT } from '../lib/playwright/amr.js'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import type { Page } from '@playwright/test'; + +const output = resolve(import.meta.dirname, '../../.tmp/ui-audit/final-design-review/current'); +test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, colorScheme: 'light', locale: 'zh-CN' }); + +type CaptureId = 'K1' | 'K3' | 'K4' | 'K5'; +const html = '

评论同步审计

已有项目内容

'; + +async function setupProject(page: Page, suffix: string) { + await mockAmrPersonalWorkspace(page); + await page.addInitScript(() => { + localStorage.setItem('open-design:locale', 'zh-CN'); + localStorage.setItem('open-design:locale-source', 'manual'); + }); + await page.route('**/api/projects/*/workspace-scope', async route => { + const projectId = new URL(route.request().url()).pathname.match(/\/api\/projects\/([^/]+)/)?.[1]; + await route.fulfill({ json: { scope: { kind: 'personal', projectId, workspaceId: AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId, visibility: 'personal', context: AMR_PERSONAL_WORKSPACE_CONTEXT } } }); + }); + const projectId = `ui-audit-owner-k-sequence-${suffix}-${Date.now()}`; + const project = await page.request.post('/api/projects', { data: { id: projectId, name: `Owner ${suffix} comment-sync capture`, skillId: null, designSystemId: null, metadata: { kind: 'prototype', nameSource: 'user' } } }); + expect(project.ok(), await project.text()).toBeTruthy(); + const { conversationId } = await project.json() as { conversationId: string }; + const file = await page.request.post(`/api/projects/${projectId}/files`, { data: { name: 'index.html', content: html, artifactManifest: { version: 1, kind: 'html', title: 'index.html', entry: 'index.html', renderer: 'html', exports: ['html'] } } }); + expect(file.ok(), await file.text()).toBeTruthy(); + const comment = await page.request.post(`/api/projects/${projectId}/conversations/${conversationId}/comments`, { data: { + note: '停用分享前已存在的项目评论', + target: { filePath: 'index.html', elementId: 'k-audit-title', selector: 'main h1', label: '标题', text: '评论同步审计', position: { x: 0.25, y: 0.25, width: 0.5, height: 0.1 }, htmlHint: '

评论同步审计

' }, + } }); + expect(comment.ok(), await comment.text()).toBeTruthy(); + const commentId = (await comment.json() as { comment: { id: string } }).comment.id; + const storedComments = await page.request.get(`/api/projects/${projectId}/conversations/${conversationId}/comments`); + expect(storedComments.ok(), await storedComments.text()).toBeTruthy(); + expect((await storedComments.json() as { comments: unknown[] }).comments).toHaveLength(1); + await page.route(`**/api/projects/${projectId}/files/index.html/share-plan`, route => route.request().method() === 'POST' + ? route.fulfill({ json: { fileCount: 1, totalBytes: html.length, exceedsSizeLimit: false, exclusions: [] } }) + : route.fallback()); + return { projectId, conversationId, commentId }; +} + +async function capture(page: Page, id: CaptureId, projectId: string, detail: Record) { + await mkdir(output, { recursive: true }); + const capturedAt = new Date().toISOString(); + const timestamp = capturedAt.replaceAll(':', '-'); + const name = `Owner-${id}-${timestamp}`; + const screenshot = resolve(output, name + '.png'); + const menu = page.locator('.share-menu-popover[role="menu"]'); + await page.screenshot({ path: screenshot, animations: 'disabled' }); + await writeFile(resolve(output, name + '.json'), JSON.stringify({ + id: name, + designId: id, + capturedAt, + source: 'current-playwright-mocked', + mockBoundary: 'Real isolated daemon project and persisted comment; real Share menu/toggle/publish UI; mocked publication service response and comment-sync-state projection only. No Vela delivery or production-account state.', + projectId, + screenshot, + viewport: page.viewportSize(), + menuText: await menu.innerText(), + bodyText: await page.locator('body').innerText(), + ...detail, + }, null, 2) + '\n'); +} + +async function openShareMenu(page: Page, projectId: string) { + await page.goto(`/projects/${projectId}/files/index.html`); + const share = page.getByTestId('file-workspace').getByRole('button', { name: '分享', exact: true }); + await expect(share).toBeVisible(); + await share.click(); + const menu = page.locator('.share-menu-popover[role="menu"]'); + await expect(menu).toBeVisible(); + return menu; +} + +async function setPublicationProjection(page: Page, projectId: string, state: 'none' | 'active' | 'stopped', slug: string) { + let publicationState = state; + const url = `https://viewer.example.test/cloud/artifact/project/${slug}`; + await page.route(`**/api/projects/${projectId}/share-state`, route => route.request().method() === 'GET' + ? route.fulfill({ json: { projectId, bindingExists: publicationState !== 'none', hasEverShared: publicationState !== 'none', publications: publicationState === 'none' ? [] : [{ sourceFilePath: 'index.html', slug, status: publicationState }] } }) + : route.fallback()); + await page.route(`**/api/projects/${projectId}/files/index.html/public-share-state`, route => route.request().method() === 'GET' + ? route.fulfill({ json: publicationState === 'none' ? { status: 'none' } : { status: publicationState, slug } }) + : route.fallback()); + await page.route(`**/api/projects/${projectId}/files/index.html/publish-public`, route => route.request().method() === 'GET' + ? route.fulfill({ json: publicationState === 'active' ? { status: 'active', freshness: 'fresh', publication: { slug, url, fileName: 'index.html' } } : { status: publicationState } }) + : route.fallback()); + return { url, slug, setState: (next: 'none' | 'active' | 'stopped') => { publicationState = next; } }; +} + +test('Owner-K1 first publication syncs existing comments', async ({ page }) => { + test.setTimeout(90_000); + await page.addInitScript(() => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: () => Promise.resolve() } }); }); + + // K1: an actual local comment exists before publish; hold the external + // publication response to capture the real upload/sync progress UI. + const k1 = await setupProject(page, 'k1'); + const k1Pub = await setPublicationProjection(page, k1.projectId, 'none', 'k1-existing-comment'); + await page.route(`**/api/projects/${k1.projectId}/comment-sync-state*`, route => route.request().method() === 'GET' + ? route.fulfill({ json: { pending: 1, sessionMissing: false, lastError: null, shareStopped: false, backfill: { state: 'pending', filePath: 'index.html', publicationRevision: 'k1-revision', retryable: true, reopened: false } } }) + : route.fallback()); + let finishK1!: () => void; + let startedK1!: () => void; + const k1Gate = new Promise(resolve => { finishK1 = resolve; }); + const k1Started = new Promise(resolve => { startedK1 = resolve; }); + await page.route(`**/api/projects/${k1.projectId}/files/index.html/publish-public`, async route => { + if (route.request().method() !== 'POST') return route.fallback(); + startedK1(); + await k1Gate; + k1Pub.setState('active'); + await route.fulfill({ json: { url: k1Pub.url, slug: k1Pub.slug, fileName: 'index.html' } }); + }); + const k1Menu = await openShareMenu(page, k1.projectId); + await k1Menu.getByRole('menuitem').filter({ hasText: /生成并复制链接|Generate and copy/ }).click(); + try { + await k1Started; + await expect(k1Menu.getByRole('progressbar')).toBeVisible(); + } finally { + finishK1(); + } + await expect(k1Menu.locator('.chrome-publish-url')).toHaveText(k1Pub.url); + const k1Banner = k1Menu.getByRole('status').filter({ hasText: /已有评论|同步/ }).first(); + await expect(k1Banner).toBeVisible(); + await expect(k1Banner).toContainText('正在同步已有评论'); + await expect(k1Banner.locator('.icon-spin, [aria-hidden="true"]')).toHaveCount(1); + await capture(page, 'K1', k1.projectId, { state: 'publication-complete-existing-comments-syncing', existingLocalCommentCount: 1, commentText: '停用分享前已存在的项目评论', publicationUrl: k1Pub.url, commentSyncProjection: { pending: 1, backfill: { state: 'pending', reopened: false, retryable: true } }, renderedSyncStatus: await k1Banner.innerText(), visualGap: 'The first-publication branch now has a dark spinner/status treatment and syncs the persisted historical comment, matching the target progress affordance. The design shows “12条”; this isolated project has one persisted comment, and the production label has no count.' }); + +}); + +test('Owner-K3 stopped link retains comments and pauses sync', async ({ page }) => { + test.setTimeout(90_000); + + // K3: the stopped service projection drives the real Share menu's disabled-link + // state; a persisted daemon comment proves stopping does not erase project data. + const k3 = await setupProject(page, 'k3'); + const k3Pub = await setPublicationProjection(page, k3.projectId, 'stopped', 'k3-retained-comments'); + await page.route(`**/api/projects/${k3.projectId}/comment-sync-state*`, route => route.request().method() === 'GET' + ? route.fulfill({ json: { pending: 0, sessionMissing: false, lastError: null, shareStopped: true } }) + : route.fallback()); + const k3Menu = await openShareMenu(page, k3.projectId); + await expect(k3Menu.getByRole('switch', { name: '链接访问' })).toHaveAttribute('aria-checked', 'false'); + await expect(k3Menu.getByText('链接已停用,评论暂停同步到分享页。')).toBeVisible(); + await expect(k3Menu.getByRole('switch', { name: '链接访问' })).toBeEnabled(); + await expect(k3Menu.getByRole('menuitem').filter({ hasText: /生成并复制链接|Generate and copy/ })).toHaveCount(0); + const k3Comments = await page.request.get(`/api/projects/${k3.projectId}/conversations/${k3.conversationId}/comments`); + expect(k3Comments.ok(), await k3Comments.text()).toBeTruthy(); + expect((await k3Comments.json() as { comments: unknown[] }).comments).toHaveLength(1); + await capture(page, 'K3', k3.projectId, { state: 'stopped-publication-state-projection', publicationUrlHidden: await k3Menu.locator('.chrome-publish-url').count() === 0, persistedCommentCountAfterStop: 1, commentSyncProjection: { shareStopped: true, pending: 0 }, projectedExistingUrl: k3Pub.url, visualGap: 'Stopped-state action now matches the target: no duplicate generate-and-copy action is shown, while the link-access switch remains enabled for resume.' }); + +}); + +test('Owner-K4/K5 distinct comment-sync capture sequences', async ({ page }) => { + test.setTimeout(180_000); + await page.addInitScript(() => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: () => Promise.resolve() } }); }); + + // K4: start from a stopped publication and resume via the switch. The local + // publication API returns the existing URL; external service projection says + // reconciliation is underway for comments changed while stopped. + const k4 = await setupProject(page, 'k4'); + const k4Resolved = await page.request.patch(`/api/projects/${k4.projectId}/conversations/${k4.conversationId}/comments/${k4.commentId}`, { data: { status: 'resolved' } }); + expect(k4Resolved.ok(), await k4Resolved.text()).toBeTruthy(); + expect((await k4Resolved.json() as { comment: { status: string } }).comment.status).toBe('resolved'); + const k4Pub = await setPublicationProjection(page, k4.projectId, 'stopped', 'k4-existing-link'); + await page.route(`**/api/projects/${k4.projectId}/files/index.html/publish-public`, route => { + if (route.request().method() !== 'POST') return route.fallback(); + k4Pub.setState('active'); + return route.fulfill({ json: { url: k4Pub.url, slug: k4Pub.slug, fileName: 'index.html' } }); + }); + await page.route(`**/api/projects/${k4.projectId}/comment-sync-state*`, route => route.request().method() === 'GET' + ? route.fulfill({ json: { pending: 1, sessionMissing: false, lastError: null, shareStopped: false, backfill: { state: 'pending', filePath: 'index.html', publicationRevision: 'k4-revision', retryable: true, reopened: true } } }) + : route.fallback()); + const k4Menu = await openShareMenu(page, k4.projectId); + await expect(k4Menu.getByRole('switch', { name: '链接访问' })).toHaveAttribute('aria-checked', 'false'); + await k4Menu.getByRole('switch', { name: '链接访问' }).click(); + await expect(k4Menu.getByRole('switch', { name: '链接访问' })).toHaveAttribute('aria-checked', 'true'); + await expect(k4Menu.locator('.chrome-publish-url')).toHaveText(k4Pub.url); + await page.reload(); + await openShareMenu(page, k4.projectId); + await expect(k4Menu.getByRole('switch', { name: '链接访问' })).toHaveAttribute('aria-checked', 'true'); + await expect(k4Menu.locator('.chrome-publish-url')).toHaveText(k4Pub.url); + const k4Banner = k4Menu.getByRole('status').filter({ hasText: '正在同步重新开启的分享链接' }); + await expect(k4Banner).toBeVisible(); + await capture(page, 'K4', k4.projectId, { state: 'resumed-existing-publication-with-reconciliation-pending', publicationUrl: k4Pub.url, persistedCommentCount: 1, stoppedPeriodCommentChange: { commentId: k4.commentId, status: 'resolved' }, commentSyncProjection: { shareStopped: false, pending: 1, backfill: { state: 'pending', reopened: true, retryable: true } }, renderedSyncStatus: await k4Banner.innerText(), visualGap: 'Current reopened state uses the normal URL/copy controls plus a reconciliation banner. Target K4 shows “正在同步评论…” in the primary action control and no exposed completed link/copy row.' }); + + // K5: actual stopped → resume UI action; service reports failed, retryable + // reconciliation. The existing link stays visible and copy remains enabled. + const k5 = await setupProject(page, 'k5'); + const k5Resolved = await page.request.patch(`/api/projects/${k5.projectId}/conversations/${k5.conversationId}/comments/${k5.commentId}`, { data: { status: 'resolved' } }); + expect(k5Resolved.ok(), await k5Resolved.text()).toBeTruthy(); + expect((await k5Resolved.json() as { comment: { status: string } }).comment.status).toBe('resolved'); + const k5Pub = await setPublicationProjection(page, k5.projectId, 'stopped', 'k5-existing-link'); + await page.route(`**/api/projects/${k5.projectId}/files/index.html/publish-public`, route => { + if (route.request().method() !== 'POST') return route.fallback(); + k5Pub.setState('active'); + return route.fulfill({ json: { url: k5Pub.url, slug: k5Pub.slug, fileName: 'index.html' } }); + }); + await page.route(`**/api/projects/${k5.projectId}/comment-sync-state*`, route => route.request().method() === 'GET' + ? route.fulfill({ json: { pending: 0, sessionMissing: false, lastError: 'backfill_failed', shareStopped: false, backfill: { state: 'failed', filePath: 'index.html', publicationRevision: 'k5-revision', retryable: true, reopened: true } } }) + : route.fallback()); + const k5Menu = await openShareMenu(page, k5.projectId); + await k5Menu.getByRole('switch', { name: '链接访问' }).click(); + await expect(k5Menu.getByRole('switch', { name: '链接访问' })).toHaveAttribute('aria-checked', 'true'); + await expect(k5Menu.locator('.chrome-publish-url')).toHaveText(k5Pub.url); + await page.reload(); + await openShareMenu(page, k5.projectId); + await expect(k5Menu.getByRole('switch', { name: '链接访问' })).toHaveAttribute('aria-checked', 'true'); + await expect(k5Menu.locator('.chrome-publish-url')).toHaveText(k5Pub.url); + const k5Banner = k5Menu.getByRole('status').filter({ hasText: '评论同步尚未完成' }); + await expect(k5Banner).toContainText('评论同步尚未完成'); + await expect(k5Banner.getByRole('button', { name: '重试' })).toBeVisible(); + await expect(k5Menu.getByRole('button', { name: '复制链接' })).toBeEnabled(); + await capture(page, 'K5', k5.projectId, { state: 'reopened-link-with-retryable-comment-backfill-failure', publicationUrl: k5Pub.url, linkUsable: true, persistedCommentCount: 1, stoppedPeriodCommentChange: { commentId: k5.commentId, status: 'resolved' }, commentSyncProjection: { shareStopped: false, backfill: { state: 'failed', reopened: true, retryable: true } }, renderedSyncStatus: await k5Banner.innerText(), visualGap: 'State semantics align (usable reopened URL, retryable unfinished reconciliation); copy differs from target: current UI adds “分享已发布” and “正在后台自动重试” text around its retry control.' }); +}); diff --git a/e2e/ui/ui-audit-od-share-k2.test.ts b/e2e/ui/ui-audit-od-share-k2.test.ts new file mode 100644 index 0000000000..dad188ca92 --- /dev/null +++ b/e2e/ui/ui-audit-od-share-k2.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from '@/playwright/suite'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { mockAmrPersonalWorkspace, AMR_PERSONAL_WORKSPACE_CONTEXT } from '../lib/playwright/amr.js'; + +test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, colorScheme: 'light', locale: 'zh-CN' }); + +test('Owner-K2 keeps failed published-comment status and retry unboxed in the Share popup', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('open-design:locale', 'zh-CN'); + localStorage.setItem('open-design:locale-source', 'manual'); + }); + await mockAmrPersonalWorkspace(page); + await page.route('**/api/projects/*/workspace-scope', async route => { + const projectId = new URL(route.request().url()).pathname.match(/\/api\/projects\/([^/]+)/)?.[1]; + await route.fulfill({ json: { scope: { kind: 'personal', projectId, workspaceId: AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId, visibility: 'personal', context: AMR_PERSONAL_WORKSPACE_CONTEXT } } }); + }); + + const projectId = `ui-audit-owner-k2-${Date.now()}`; + const project = await page.request.post('/api/projects', { data: { id: projectId, name: 'Owner K2 retry fixture', skillId: null, designSystemId: null, metadata: { kind: 'prototype', nameSource: 'user' } } }); + expect(project.ok(), await project.text()).toBeTruthy(); + const content = '

审计产物

'; + const file = await page.request.post(`/api/projects/${projectId}/files`, { data: { name: 'index.html', content, artifactManifest: { version: 1, kind: 'html', title: 'index.html', entry: 'index.html', renderer: 'html', exports: ['html'] } } }); + expect(file.ok(), await file.text()).toBeTruthy(); + + await page.route(`**/api/projects/${projectId}/share-state`, route => route.request().method() === 'GET' + ? route.fulfill({ json: { projectId, bindingExists: true, hasEverShared: true, publications: [{ sourceFilePath: 'index.html', slug: 'k2-published', status: 'active' }] } }) + : route.fallback()); + await page.route(`**/api/projects/${projectId}/files/index.html/publish-public`, route => route.request().method() === 'GET' + ? route.fulfill({ json: { status: 'active', freshness: 'fresh', publication: { slug: 'k2-published', url: 'https://viewer.example.test/cloud/artifact/project/k2-published', fileName: 'index.html' } } }) + : route.fallback()); + + let projection: Record = { + pending: 0, + lastError: 'COMMENT_BACKFILL_PUSH_FAILED', + sessionMissing: false, + shareStopped: false, + backfill: { state: 'failed', filePath: 'index.html', publicationRevision: 'k2-r1', retryable: true }, + }; + await page.route(`**/api/projects/${projectId}/comment-sync-state*`, route => route.fulfill({ json: projection })); + await page.goto(`/projects/${projectId}/files/index.html`); + await page.getByTestId('file-workspace').getByRole('button', { name: '分享', exact: true }).click(); + const menu = page.locator('.share-menu-popover[role="menu"]'); + await expect(menu).toBeVisible(); + const status = menu.getByRole('status'); + await expect(status).toContainText('分享已发布'); + await expect(status).toContainText('已有评论暂未同步'); + const retry = status.getByRole('button', { name: '重试' }); + await expect(retry).toBeVisible(); + const statusStyle = await status.evaluate(element => ({ border: getComputedStyle(element).borderTopWidth, background: getComputedStyle(element).backgroundColor })); + expect(statusStyle).toEqual({ border: '0px', background: 'rgba(0, 0, 0, 0)' }); + await expect(menu.getByText('https://viewer.example.test/cloud/artifact/project/k2-published')).toBeVisible(); + await expect(menu.getByRole('button', { name: '复制链接' })).toBeVisible(); + const output = resolve(import.meta.dirname, '../../.tmp/ui-audit/final-design-review/current'); + await mkdir(output, { recursive: true }); + const captureId = `Owner-K2-${new Date().toISOString().replace(/[:.]/g, '-')}`; + const screenshot = resolve(output, `${captureId}.png`); + await page.screenshot({ path: screenshot, fullPage: true, animations: 'disabled' }); + await writeFile(resolve(output, `${captureId}.json`), JSON.stringify({ + id: captureId, + capturedAt: new Date().toISOString(), + source: 'current-playwright-mocked', + mockBoundary: 'GET comment-sync-state returns a persisted failed/retryable backfill projection; the popup preserves status and retry in an unboxed presentation.', + projectId, + backfill: projection.backfill, + screenshot, + viewport: page.viewportSize(), + text: await menu.innerText(), + }, null, 2) + '\n'); + +}); diff --git a/e2e/ui/ui-audit-od-share-remaining.test.ts b/e2e/ui/ui-audit-od-share-remaining.test.ts new file mode 100644 index 0000000000..b472a7d98c --- /dev/null +++ b/e2e/ui/ui-audit-od-share-remaining.test.ts @@ -0,0 +1,191 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import type { Page } from '@playwright/test'; +import { expect, test } from '@/playwright/suite'; +import { mockAmrPersonalWorkspace, AMR_PERSONAL_WORKSPACE_CONTEXT } from '../lib/playwright/amr.js'; + +const output = resolve(import.meta.dirname, '../../.tmp/ui-audit/final-design-review/current'); +const captureTime = new Date().toISOString().replace(/[:.]/g, '-'); +test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, colorScheme: 'light', locale: 'zh-CN' }); + +async function capture(page: Page, id: string, detail: Record = {}) { + const name = `Owner-${id}-${captureTime}`; + await mkdir(output, { recursive: true }); + await page.screenshot({ path: resolve(output, `${name}.png`), animations: 'disabled' }); + await writeFile(resolve(output, `${name}.json`), JSON.stringify({ + id: name, capturedAt: new Date().toISOString(), source: 'current-playwright-mocked', mocked: true, + observedState: id, viewport: page.viewportSize(), text: await page.locator('body').innerText(), + feedback: await page.locator('[role="status"], [role="alert"]').evaluateAll(elements => elements + .filter(element => { const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }) + .map(element => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { role: element.getAttribute('role'), text: element.textContent?.trim(), + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + backgroundColor: style.backgroundColor, color: style.color, borderRadius: style.borderRadius, boxShadow: style.boxShadow }; + })), + ...detail, + }, null, 2)); +} + +async function createHtmlProject(page: Page, projectId: string) { + await mockAmrPersonalWorkspace(page); + await page.addInitScript(() => { + localStorage.setItem('open-design:locale', 'zh-CN'); + localStorage.setItem('open-design:locale-source', 'manual'); + }); + await page.route('**/api/projects/*/workspace-scope', async route => { + const id = new URL(route.request().url()).pathname.match(/\/api\/projects\/([^/]+)/)?.[1]; + await route.fulfill({ json: { scope: { kind: 'personal', projectId: id, + workspaceId: AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId, visibility: 'personal', context: AMR_PERSONAL_WORKSPACE_CONTEXT } } }); + }); + const created = await page.request.post('/api/projects', { data: { + id: projectId, name: 'Share audit isolated project', skillId: null, designSystemId: null, + metadata: { kind: 'prototype', nameSource: 'user' }, + } }); + expect(created.ok(), await created.text()).toBeTruthy(); + const { conversationId } = await created.json() as { conversationId: string }; + const html = '

Share audit

Existing review comment target

'; + const file = await page.request.post(`/api/projects/${projectId}/files`, { data: { + name: 'index.html', content: html, + artifactManifest: { version: 1, kind: 'html', title: 'index.html', entry: 'index.html', renderer: 'html', exports: ['html'] }, + } }); + expect(file.ok(), await file.text()).toBeTruthy(); + const now = Date.now(); + const messageId = `ui-audit-share-${projectId}`; + const message = await page.request.put(`/api/projects/${projectId}/conversations/${conversationId}/messages/${messageId}`, { data: { + id: messageId, role: 'assistant', content: '已生成审计产物。', runStatus: 'succeeded', + startedAt: now - 2000, endedAt: now - 1000, createdAt: now - 1500, + events: [{ kind: 'tool_use', id: 'write-audit', name: 'Write', input: { file_path: `/project/${projectId}/index.html`, content: html } }, + { kind: 'tool_result', toolUseId: 'write-audit', content: 'ok', isError: false }, + { kind: 'text', text: '已生成审计产物。' }, { kind: 'artifact_focus', show: ['index.html'] }], + producedFiles: [{ name: 'index.html', path: 'index.html', localPath: `/project/${projectId}/index.html`, type: 'file', size: html.length, mtime: now - 1000, kind: 'html', mime: 'text/html' }], + } }); + expect(message.ok(), await message.text()).toBeTruthy(); + await page.goto(`/projects/${projectId}/conversations/${conversationId}`); + const card = page.getByTestId('artifact-card-publish-index.html'); + await expect(card).toBeVisible(); + await card.click(); + const share = page.locator('.share-menu-popover[role="menu"]'); + await expect(share).toBeVisible(); + return { projectId, conversationId, share }; +} + +test('capture remaining OD share audit states from visible product UI', async ({ page }) => { + test.setTimeout(120_000); + const projectId = `ui-audit-share-remaining-${Date.now()}`; + const { share } = await createHtmlProject(page, projectId); + + // G4 is a negative assertion on the real shared entry, not a synthetic screen. + await expect(share.getByText(/快速分享|Quick share|Social share/)).toHaveCount(0); + await capture(page, 'G4'); + + // S5/S6 are captured only if the authoritative read actually offers an update + // action. A made-up stale button/state would not establish the UI contract. + const publishPath = `**/api/projects/${projectId}/files/index.html/publish-public`; + const shareStatePath = publishPath; + const oldUrl = `https://example.test/artifact/${projectId}/prior-link`; + await page.route(shareStatePath, route => route.request().method() === 'GET' + ? route.fulfill({ json: { status: 'active', freshness: 'outdated', publication: { url: oldUrl, slug: 'prior-link', fileName: 'index.html' } } }) + : route.fallback()); + await page.reload(); + await expect(page.getByTestId('artifact-card-publish-index.html')).toBeVisible(); + await page.getByTestId('artifact-card-publish-index.html').click(); + const updatedShare = page.locator('.share-menu-popover[role="menu"]'); + const update = updatedShare.getByRole('button', { name: /更新链接|Update link/ }); + await expect(update).toBeVisible(); + await expect(updatedShare.locator('.chrome-publish-url')).toContainText(oldUrl); + await expect(updatedShare.getByRole('button', { name: /复制链接|Copy share link/ })).toBeVisible(); + await capture(page, 'S5', { priorUrl: oldUrl }); + + let finish!: () => void; + const gate = new Promise(resolveGate => { finish = resolveGate; }); + await page.route(publishPath, async route => { + if (route.request().method() !== 'POST') return route.fallback(); + await gate; + await route.fulfill({ json: { url: oldUrl, slug: 'prior-link', fileName: 'index.html' } }); + }); + const request = page.waitForRequest(req => req.method() === 'POST' && req.url().includes('/publish-public')); + await update.click(); + await request; + await expect(update).toHaveAttribute('aria-busy', 'true'); + await capture(page, 'S6'); + finish(); + await expect(page.getByRole('status').filter({ hasText: /已更新|updated/i })).toBeVisible(); + await capture(page, 'S6-OK'); + + // Re-arm the real update affordance and fail that user action (not a + // synthetic toast injection) to capture the product's error feedback. + await page.route(publishPath, route => route.request().method() === 'GET' + ? route.fulfill({ json: { status: 'active', freshness: 'outdated', publication: { url: oldUrl, slug: 'prior-link', fileName: 'index.html' } } }) + : route.request().method() === 'POST' + ? route.fulfill({ status: 500, json: { error: { code: 'ui_audit_update_failed', message: 'Audit update failure' } } }) + : route.fallback()); + await page.reload(); + await page.getByTestId('artifact-card-publish-index.html').click(); + const retryShare = page.locator('.share-menu-popover[role="menu"]'); + const retryUpdate = retryShare.getByRole('button', { name: /更新链接|Update link/ }); + await expect(retryUpdate).toBeVisible(); + await retryUpdate.click(); + await expect(page.getByRole('alert')).toBeVisible(); + await capture(page, 'S6-ERR'); +}); + +async function openDeleteConfirmation(page: Page, projectId: string) { + await page.getByTestId('workspace-tabs-dropdown-trigger').click(); + const row = page.getByTestId('workspace-tabs-dropdown').locator('.workspace-tabs-dropdown__row') + .filter({ hasText: 'Share audit isolated project' }).first(); + await expect(row).toBeVisible(); + await row.getByTestId('workspace-tabs-dropdown-row-more').click(); + await page.getByTestId('workspace-tabs-dropdown-row-menu').getByRole('menuitem', { name: /删除|Delete/ }).click(); + const confirm = page.getByTestId('project-delete-confirm-dialog'); + await expect(confirm).toBeVisible(); + await expect(confirm).toContainText(/分享链接将失效|分享链接.*无法再打开/); + return confirm; +} + +test('capture S14 historical share deletion warning from workspace menu', async ({ page }) => { + const projectId = `ui-audit-s14-${Date.now()}`; + await page.route(`**/api/projects/${projectId}/share-state`, route => route.request().method() === 'GET' + ? route.fulfill({ json: { projectId, hasEverShared: true, bindingExists: true, + publications: [{ sourceFilePath: 'index.html', slug: 'ui-audit-old-link', status: 'active' }] } }) + : route.fallback()); + await createHtmlProject(page, projectId); + const historyRead = page.waitForResponse(response => response.url().includes(`/api/projects/${projectId}/share-state`) + && response.request().method() === 'GET' && response.ok()); + await page.evaluate(() => window.dispatchEvent(new Event('focus'))); + await historyRead; + const confirm = await openDeleteConfirmation(page, projectId); + await capture(page, 'S14', { projectId, historicalSlug: 'ui-audit-old-link' }); + await page.getByTestId('project-delete-confirm-cancel').click(); + await expect(confirm).toHaveCount(0); +}); + +test('capture S14-ERR residual unshare failure after real delete confirmation', async ({ page }) => { + const projectId = `ui-audit-s14-error-${Date.now()}`; + await page.route(`**/api/projects/${projectId}/share-state`, route => route.request().method() === 'GET' + ? route.fulfill({ json: { projectId, hasEverShared: true, bindingExists: true, + publications: [{ sourceFilePath: 'index.html', slug: 'ui-audit-old-link', status: 'active' }] } }) + : route.fallback()); + await createHtmlProject(page, projectId); + const historyRead = page.waitForResponse(response => response.url().includes(`/api/projects/${projectId}/share-state`) + && response.request().method() === 'GET' && response.ok()); + await page.evaluate(() => window.dispatchEvent(new Event('focus'))); + await historyRead; + await page.route(`**/api/projects/${projectId}`, route => route.request().method() === 'DELETE' + ? route.fulfill({ json: { shareResiduals: [{ filePath: 'index.html', slug: 'ui-audit-old-link', retrying: false }] } }) + : route.fallback()); + const confirm = await openDeleteConfirmation(page, projectId); + const deletion = page.waitForResponse(response => response.url().includes(`/api/projects/${projectId}`) + && response.request().method() === 'DELETE'); + await page.getByTestId('project-delete-confirm-accept').click(); + expect((await deletion).ok()).toBeTruthy(); + await expect(page.getByRole('alert').filter({ hasText: /index\.html.*链接停用失败/ })).toBeVisible(); + await capture(page, 'S14-ERR', { projectId, residualSlug: 'ui-audit-old-link' }); + await page.route('**/api/public-file-stops/retry', route => route.fulfill({ json: { status: 'stopped', projectId, filePath: 'index.html', slug: 'ui-audit-old-link' } })); + const retryRequest = page.waitForRequest(request => request.url().endsWith('/api/public-file-stops/retry') && request.method() === 'POST'); + await page.getByRole('button', { name: '重试停用' }).click(); + expect((await retryRequest).postDataJSON()).toEqual({ projectId, filePath: 'index.html', slug: 'ui-audit-old-link' }); + await expect(page.getByRole('alert').filter({ hasText: /index\.html.*链接停用失败/ })).toHaveCount(0); + await expect(confirm).toHaveCount(0); +}); diff --git a/e2e/ui/ui-audit-od-share-reopen.test.ts b/e2e/ui/ui-audit-od-share-reopen.test.ts new file mode 100644 index 0000000000..61d7c2208e --- /dev/null +++ b/e2e/ui/ui-audit-od-share-reopen.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from '@/playwright/suite'; +import { mockAmrPersonalWorkspace, AMR_PERSONAL_WORKSPACE_CONTEXT } from '../lib/playwright/amr.js'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, colorScheme: 'light', locale: 'zh-CN' }); + +test('capture Owner-S9-R while resume-only request is pending', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('open-design:locale', 'zh-CN'); + localStorage.setItem('open-design:locale-source', 'manual'); + }); + await mockAmrPersonalWorkspace(page); + await page.route('**/api/projects/*/workspace-scope', async route => { + const projectId = new URL(route.request().url()).pathname.match(/\/api\/projects\/([^/]+)/)?.[1]; + await route.fulfill({ json: { scope: { kind: 'personal', projectId, workspaceId: AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId, visibility: 'personal', context: AMR_PERSONAL_WORKSPACE_CONTEXT } } }); + }); + const projectId = `ui-audit-reopen-${Date.now()}`; + const project = await page.request.post('/api/projects', { data: { id: projectId, name: 'S9-R isolated capture', skillId: null, designSystemId: null, metadata: { kind: 'prototype', nameSource: 'user' } } }); + expect(project.ok(), await project.text()).toBeTruthy(); + const { conversationId } = await project.json() as { conversationId: string }; + const content = '

审计产物

'; + const file = await page.request.post(`/api/projects/${projectId}/files`, { data: { name: 'index.html', content, artifactManifest: { version: 1, kind: 'html', title: 'index.html', entry: 'index.html', renderer: 'html', exports: ['html'] } } }); + expect(file.ok(), await file.text()).toBeTruthy(); + await page.route(`**/api/projects/${projectId}/files/index.html/share-plan`, route => route.fulfill({ json: { fileCount: 1, totalBytes: content.length, exceedsSizeLimit: false, exclusions: [] } })); + const now = Date.now(); + await page.request.put(`/api/projects/${projectId}/conversations/${conversationId}/messages/s9r-fixture`, { data: { id: 's9r-fixture', role: 'assistant', content: '已生成产物。', runStatus: 'succeeded', startedAt: now - 2000, endedAt: now - 1000, createdAt: now - 1500, events: [{ kind: 'tool_use', id: 'write-index', name: 'Write', input: { file_path: `/project/${projectId}/index.html`, content } }, { kind: 'tool_result', toolUseId: 'write-index', content: 'ok', isError: false }, { kind: 'text', text: '已生成产物。' }, { kind: 'artifact_focus', show: ['index.html'] }], producedFiles: [{ name: 'index.html', path: 'index.html', localPath: `/project/${projectId}/index.html`, type: 'file', size: content.length, mtime: now - 1000, kind: 'html', mime: 'text/html' }] } }); + await page.route(`**/api/projects/${projectId}/share-state`, route => route.fulfill({ json: { projectId, bindingExists: true, hasEverShared: true, publications: [{ sourceFilePath: 'index.html', slug: 's9r-original', status: 'stopped' }] } })); + await page.route(`**/api/projects/${projectId}/files/index.html/public-share-state`, route => route.fulfill({ json: { status: 'stopped', slug: 's9r-original' } })); + await page.goto(`/projects/${projectId}/files/index.html`); + const share = page.getByTestId('file-workspace').getByRole('button', { name: '分享', exact: true }); + await share.click(); + const menu = page.locator('.share-menu-popover[role="menu"]'); + await expect(menu).toBeVisible(); + let release!: () => void; + let started!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const requestStarted = new Promise(resolve => { started = resolve; }); + await page.route(`**/api/projects/${projectId}/files/index.html/publish-public`, async route => { + if (route.request().method() !== 'POST') return route.continue(); + expect(route.request().postDataJSON()).toEqual({ mode: 'resume' }); + started(); + await gate; + await route.fulfill({ json: { url: 'https://viewer.example.test/cloud/artifact/project/s9r-original', slug: 's9r-original', fileName: 'index.html' } }); + }); + await menu.getByRole('switch', { name: '链接访问' }).click(); + try { + await requestStarted; + await expect(menu.getByRole('menuitem', { name: '正在开启…' })).toBeDisabled(); + await expect(menu.getByRole('menuitem', { name: '正在开启…' })).toBeVisible(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(menu.getByRole('progressbar')).toHaveCount(0); + await expect(menu.getByText('上传中 0%')).toHaveCount(0); + await expect(menu.getByText('关闭面板不会中断上传。')).toHaveCount(0); + const output = resolve(import.meta.dirname, '../../.tmp/ui-audit/final-design-review/current'); + await mkdir(output, { recursive: true }); + const capturedAt = new Date().toISOString(); + const timestamp = capturedAt.replaceAll(':', '-'); + const screenshotName = 'Owner-S9-R-' + timestamp; + const screenshotPath = resolve(output, screenshotName + '.png'); + await page.screenshot({ path: screenshotPath }); + await expect(menu.getByRole('menuitem', { name: '正在开启…' })).toBeVisible(); + const busyBackground = await menu.getByRole('menuitem', { name: '正在开启…' }).evaluate(element => getComputedStyle(element).backgroundColor); + expect(busyBackground).toMatch(/^rgb\(/); + await writeFile(resolve(output, screenshotName + '.json'), JSON.stringify({ + capturedAt, + source: 'current-playwright-mocked', + mockBoundary: 'Real daemon project and UI; stopped publication GET and resume POST service response are Playwright routes. Pending resume request remains gated during capture.', + projectId, + publicationStatus: 'stopped', + resumeMode: 'resume', + screenshot: screenshotPath, + menuText: await menu.innerText(), + publicUrl: 'https://viewer.example.test/cloud/artifact/project/s9r-original', + }, null, 2) + '\n'); + } finally { + release(); + } + await expect(menu.locator('.chrome-publish-url')).toHaveText('https://viewer.example.test/cloud/artifact/project/s9r-original'); +}); diff --git a/e2e/ui/ui-audit-od-share-special-states.test.ts b/e2e/ui/ui-audit-od-share-special-states.test.ts new file mode 100644 index 0000000000..4986e1726d --- /dev/null +++ b/e2e/ui/ui-audit-od-share-special-states.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from '@/playwright/suite'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import type { Page } from '@playwright/test'; +import { mockAmrPersonalWorkspace, AMR_PERSONAL_WORKSPACE_CONTEXT } from '../lib/playwright/amr.js'; + +const output = resolve(import.meta.dirname, '../../.tmp/ui-audit/final-design-review/current'); +const captureTime = new Date().toISOString().replace(/[:.]/g, '-'); +test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, colorScheme: 'light', locale: 'zh-CN' }); + +async function capture(page: Page, id: 'G3' | 'S13', detail: Record) { + const name = 'Owner-' + id + '-' + captureTime; + await mkdir(output, { recursive: true }); + const screenshot = resolve(output, name + '.png'); + const capturedAt = new Date().toISOString(); + await page.screenshot({ path: screenshot, animations: 'disabled' }); + await writeFile(resolve(output, name + '.json'), JSON.stringify({ id: name, capturedAt, source: 'current-playwright-mocked', mockBoundary: 'Local daemon project/files and controlled publication, workspace-scope, and login-status projections. No production-account or real Vela delivery.', screenshot, viewport: page.viewportSize(), text: await page.locator('body').innerText(), ...detail }, null, 2) + String.fromCharCode(10)); +} + +async function personalProject(page: Page, projectId: string, name: string) { + await mockAmrPersonalWorkspace(page); + await page.addInitScript(() => { localStorage.setItem('open-design:locale', 'zh-CN'); localStorage.setItem('open-design:locale-source', 'manual'); }); + await page.route('**/api/projects/*/workspace-scope', async route => { + const id = new URL(route.request().url()).pathname.match(/\/api\/projects\/([^/]+)/)?.[1]; + await route.fulfill({ json: { scope: { kind: 'personal', projectId: id, workspaceId: AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId, visibility: 'personal', context: AMR_PERSONAL_WORKSPACE_CONTEXT } } }); + }); + const response = await page.request.post('/api/projects', { data: { id: projectId, name, skillId: null, designSystemId: null, metadata: { kind: 'prototype', nameSource: 'user' } } }); + expect(response.ok(), await response.text()).toBeTruthy(); + return (await response.json() as { conversationId: string }).conversationId; +} + +test('Owner-G3 multi-file React module disables Share with its reason in the real viewer', async ({ page }) => { + const projectId = 'ui-audit-owner-g3-' + Date.now(); + const conversationId = await personalProject(page, projectId, 'Owner G3 multi-file React fixture'); + const files = [ + { name: 'src/App.jsx', content: "import Badge from './Badge.jsx';\nexport default function App(){ return

React module fixture

; }", artifactManifest: { version: 1, kind: 'react-component', title: 'App.jsx', entry: 'src/App.jsx', renderer: 'react-component', exports: ['jsx', 'html', 'zip'] } }, + { name: 'src/Badge.jsx', content: 'export default function Badge(){ return

Support module

; }', artifactManifest: { version: 1, kind: 'react-component', title: 'Badge.jsx', entry: 'src/Badge.jsx', renderer: 'react-component', exports: ['jsx', 'html', 'zip'] } }, + { name: 'index.html', content: '
', artifactManifest: { version: 1, kind: 'html', title: 'index.html', entry: 'index.html', renderer: 'html', exports: ['html'] } }, + ]; + for (const fileData of files) { + const response = await page.request.post('/api/projects/' + projectId + '/files', { data: fileData }); + expect(response.ok(), await response.text()).toBeTruthy(); + } + await page.goto('/projects/' + projectId + '/files/src/App.jsx'); + const viewer = page.locator('.react-component-viewer'); + await expect(viewer).toBeVisible(); + const share = viewer.getByRole('button', { name: '分享', exact: true }); + const exportButton = viewer.getByRole('button', { name: '导出', exact: true }); + await expect(exportButton).toBeEnabled(); + await expect(share).toBeDisabled(); + const disabledReason = await share.getAttribute('title'); + expect(disabledReason).toBeTruthy(); + await capture(page, 'G3', { projectId, conversationId, fileNames: files.map(file => file.name), viewer: 'react-component-viewer', exportEnabled: await exportButton.isEnabled(), disabledShareReason: disabledReason }); +}); + +test('Owner-S13 keeps the old published link readable and copyable without a signed-in workspace', async ({ page }) => { + const projectId = 'ui-audit-owner-s13-' + Date.now(); + const conversationId = await personalProject(page, projectId, 'Owner S13 published link fixture'); + let signedOut = false; + let scopeReadsAfterSignOut = 0; + let loginStatusReadsAfterSignOut = 0; + await page.route('**/api/workspace/directory', route => signedOut && route.request().method() === 'GET' ? route.fulfill({ json: { items: [], activeWorkspaceId: null } }) : route.fallback()); + await page.route('**/api/workspace/context', route => signedOut && route.request().method() === 'GET' ? route.fulfill({ json: { context: null } }) : route.fallback()); + await page.route('**/api/projects/' + projectId + '/workspace-scope*', route => { + if (signedOut) { scopeReadsAfterSignOut += 1; return route.fulfill({ status: 503, json: { error: { code: 'WORKSPACE_UNAVAILABLE' } } }); } + return route.fulfill({ json: { scope: { kind: 'personal', projectId, workspaceId: AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId, visibility: 'personal', context: AMR_PERSONAL_WORKSPACE_CONTEXT } } }); + }); + await page.route('**/api/integrations/vela/status*', route => { + if (signedOut) loginStatusReadsAfterSignOut += 1; + return route.fulfill({ json: { loggedIn: !signedOut, profile: signedOut ? null : 'default', configPath: '', user: signedOut ? null : { id: AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceMemberId, email: 'fixture@example.invalid' } } }); + }); + const html = '

Previously published review

'; + const file = await page.request.post('/api/projects/' + projectId + '/files', { data: { name: 'index.html', content: html, artifactManifest: { version: 1, kind: 'html', title: 'index.html', entry: 'index.html', renderer: 'html', exports: ['html'] } } }); + expect(file.ok(), await file.text()).toBeTruthy(); + // A published project is Workspace-bound: the daemon fixture owns its file, + // while these catalog projections model the already-established binding. + const projectResponse = await page.request.get('/api/projects/' + projectId); + expect(projectResponse.ok(), await projectResponse.text()).toBeTruthy(); + const localProject = (await projectResponse.json() as { project: Record }).project; + const workspaceId = AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId; + const memberId = AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceMemberId; + const boundProject = { ...localProject, workspaceId, workspaceVisibility: 'private' }; + await page.route('**/api/projects', route => route.request().method() === 'GET' + ? route.fulfill({ json: { projects: [] } }) : route.fallback()); + await page.route('**/api/workspaces/' + workspaceId + '/projects?*', route => route.request().method() === 'GET' + ? route.fulfill({ json: { projects: [{ project: boundProject, workspaceId, + visibility: 'private', resourceState: 'active', createdByWorkspaceMemberId: memberId, + updatedByWorkspaceMemberId: memberId, currentUserAccess: { + canOpen: true, canRename: true, canDelete: true, canDuplicate: true, + canMoveToTeam: false, canMoveToPersonal: true, canExport: true, + canSendTo: true, canRestoreVersion: true, + } }] } }) : route.fallback()); + await page.route('**/api/projects/' + projectId, route => route.request().method() === 'GET' + ? route.fulfill({ json: { project: boundProject } }) : route.fallback()); + const slug = 's13-prior-' + projectId; + const publicUrl = 'https://viewer.example.test/cloud/artifact/project/' + slug; + await page.route('**/api/projects/' + projectId + '/share-state', route => route.request().method() === 'GET' ? route.fulfill({ json: { projectId, bindingExists: true, hasEverShared: true, publications: [{ sourceFilePath: 'index.html', slug, status: 'active' }] } }) : route.fallback()); + await page.route('**/api/projects/' + projectId + '/files/index.html/publish-public', route => route.request().method() === 'GET' ? route.fulfill({ json: { status: 'active', freshness: 'outdated', publication: { slug, url: publicUrl, fileName: 'index.html' } } }) : route.fallback()); + await page.goto('/projects/' + projectId + '/files/index.html'); + const fileWorkspace = page.getByTestId('file-workspace'); + await expect(fileWorkspace).toBeVisible(); + const share = fileWorkspace.getByRole('button', { name: '分享', exact: true }); + await expect(share).toBeEnabled(); + await share.click(); + const menu = page.locator('.share-menu-popover[role=\"menu\"]'); + await expect(menu).toBeVisible(); + await expect(menu.locator('.chrome-publish-url')).toHaveText(publicUrl); + await page.evaluate(() => Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: (text: string) => { document.body.dataset.copiedUrl = text; return Promise.resolve(); } } })); + const copy = menu.getByRole('button', { name: /复制链接|复制分享链接/ }); + await expect(copy).toBeVisible(); + await copy.click(); + await expect.poll(() => page.locator('body').getAttribute('data-copied-url')).toBe(publicUrl); + signedOut = true; + await page.evaluate(() => { + window.dispatchEvent(new Event('od:amr-login-status-change')); + window.dispatchEvent(new Event('od:workspace-context-refresh')); + }); + await expect.poll(() => loginStatusReadsAfterSignOut).toBeGreaterThan(0); + await expect(fileWorkspace).toHaveCount(0); + const oldLink = page.locator('code').filter({ hasText: publicUrl }); + await expect(oldLink).toHaveText(publicUrl); + await expect(page.getByRole('switch', { name: /链接访问/ })).toBeDisabled(); + const loginToUpdate = page.getByRole('button', { name: /登录后更新/ }); + await expect(loginToUpdate).toBeEnabled(); + await page.evaluate(() => { delete document.body.dataset.copiedUrl; }); + await page.getByRole('button', { name: /复制链接|复制分享链接/ }).click(); + await expect.poll(() => page.locator('body').getAttribute('data-copied-url')).toBe(publicUrl); + await capture(page, 'S13', { projectId, conversationId, publication: { slug, status: 'active', url: publicUrl }, + signedOutProject: 'unlisted after auth refresh', workspaceScopeReadsAfterSignOut: scopeReadsAfterSignOut, + interaction: 'Old authenticated link copied before and after real sign-out; file source unmounted; login-to-update visible, auth recovery not exercised', + fallbackText: await page.locator('body').innerText() }); +}); diff --git a/e2e/ui/ui-audit-od-share.test.ts b/e2e/ui/ui-audit-od-share.test.ts new file mode 100644 index 0000000000..17304274a7 --- /dev/null +++ b/e2e/ui/ui-audit-od-share.test.ts @@ -0,0 +1,621 @@ +import { expect, test } from '@/playwright/suite'; +import { mockAmrPersonalWorkspace, AMR_PERSONAL_WORKSPACE_CONTEXT } from '../lib/playwright/amr.js'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import type { Page } from '@playwright/test'; +const out = resolve(import.meta.dirname,'../../.tmp/ui-audit/final-design-review/current'); +const captureRun = new Date().toISOString().replace(/[:.]/g, '-'); +test.use({ viewport:{width:1440,height:900},deviceScaleFactor:2,colorScheme:'light',locale:'zh-CN' }); +async function save(page:Page,id:string,observedState:string,detail:Record={}){ + if (!new Set(['G1','G2','S1','S2','S3','S4','S4-C','S7','S9','S9-R','S10','S12','S15','C0','K2','K5','P1','S0','S1-T','S1-T2','S4-T','S14','S14-ERR']).has(id)) return; + const captureId=`Owner-${id}-${captureRun}`; + await mkdir(out,{recursive:true}); + const elements=await page.locator('body').evaluate(node=>[node,...Array.from(node.querySelectorAll('h1,h2,h3,button,[role="menu"],[role="menuitem"],[role="status"], [data-testid="artifact-card-publish-index.html"]')).slice(0,80)].map((el,index)=>{const s=getComputedStyle(el),r=el.getBoundingClientRect();return {index,tag:el.tagName.toLowerCase(),text:(el.textContent??'').trim().slice(0,160),disabled:el instanceof HTMLButtonElement?el.disabled:undefined,title:el.getAttribute('title')??undefined,style:{color:s.color,backgroundColor:s.backgroundColor,fontSize:s.fontSize,fontWeight:s.fontWeight,lineHeight:s.lineHeight,borderRadius:s.borderRadius,boxShadow:s.boxShadow,width:r.width,height:r.height,padding:s.padding,margin:s.margin}}})); + await page.screenshot({path:resolve(out,`${captureId}.png`),animations:'disabled'}); + await writeFile(resolve(out,`${captureId}.json`),JSON.stringify({id:captureId,capturedAt:new Date().toISOString(),source:'current-playwright-mocked',mockBoundary:'Local Playwright route mocks and fixture-backed local daemon data; not a real Vela/share-service delivery or real-account state.',observedState,viewport:page.viewportSize(),text:await page.locator('body').innerText(),elements,...detail},null,2)); +} +test('capture isolated OD share entry, progress and failure states',async({page})=>{ + test.setTimeout(120_000); + await page.addInitScript(()=>{localStorage.setItem('open-design:locale','zh-CN');localStorage.setItem('open-design:locale-source','manual');}); + await mockAmrPersonalWorkspace(page); + await page.route('**/api/projects/*/workspace-scope',async r=>{const id=new URL(r.request().url()).pathname.match(/\/api\/projects\/([^/]+)/)?.[1];await r.fulfill({json:{scope:{kind:'personal',projectId:id,workspaceId:AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId,visibility:'personal',context:AMR_PERSONAL_WORKSPACE_CONTEXT}}});}); + const projectId=`ui-audit-${Date.now()}`; + const response=await page.request.post('/api/projects',{data:{id:projectId,name:'UI audit isolated fixture',skillId:null,designSystemId:null,metadata:{kind:'prototype',nameSource:'user'}}}); + expect(response.ok(),await response.text()).toBeTruthy(); + const {conversationId}=await response.json() as {conversationId:string}; + const content='

审计产物

Owner 注释区域

'; + const file=await page.request.post(`/api/projects/${projectId}/files`,{data:{name:'index.html',content,artifactManifest:{version:1,kind:'html',title:'index.html',entry:'index.html',renderer:'html',exports:['html']}}}); + expect(file.ok(),await file.text()).toBeTruthy(); + await page.route(`**/api/projects/${projectId}/files/index.html/share-plan`,r=>r.request().method()==='POST'?r.fulfill({json:{fileCount:1,totalBytes:content.length,exceedsSizeLimit:false,exclusions:[]}}):r.fallback()); + const now=Date.now(); + const message=await page.request.put(`/api/projects/${projectId}/conversations/${conversationId}/messages/ui-audit-assistant`,{data:{id:'ui-audit-assistant',role:'assistant',content:'已生成产物。',runStatus:'succeeded',startedAt:now-2000,endedAt:now-1000,createdAt:now-1500,events:[{kind:'tool_use',id:'write-index',name:'Write',input:{file_path:`/project/${projectId}/index.html`,content}},{kind:'tool_result',toolUseId:'write-index',content:'ok',isError:false},{kind:'text',text:'已生成产物。'},{kind:'artifact_focus',show:['index.html']}],producedFiles:[{name:'index.html',path:'index.html',localPath:`/project/${projectId}/index.html`,type:'file',size:content.length,mtime:now-1000,kind:'html',mime:'text/html'}]}}); + expect(message.ok(),await message.text()).toBeTruthy(); + await page.goto(`/projects/${projectId}/conversations/${conversationId}`); + const card=page.getByTestId('artifact-card-publish-index.html'); + await expect(card).toBeVisible({timeout:20000}); + await save(page,'G1-entry','G1-entry'); + // G1 is the global top-right Share entry, not the artifact-card shortcut. + await page.goto(`/projects/${projectId}/files/index.html`); + const g1ToolbarShare=page.getByTestId('file-workspace').getByRole('button',{name:'分享',exact:true}); + await expect(g1ToolbarShare).toBeVisible(); + await g1ToolbarShare.click(); + const g1ToolbarMenu=page.locator('.share-menu-popover[role="menu"]'); + await expect(g1ToolbarMenu).toBeVisible(); + await save(page,'G1','G1',{entry:'file-workspace top-right share button'}); + await g1ToolbarMenu.getByRole('button',{name:/关闭|Close/}).click(); + await page.goto(`/projects/${projectId}/conversations/${conversationId}`); + await expect(card).toBeVisible(); + await card.click(); + const menu=page.locator('.share-menu-popover[role="menu"]'); + await expect(menu).toBeVisible(); + await expect(menu.getByText(/快速分享|分享至社交|Quick share|Social share/)).toHaveCount(0); + await save(page,'S1','S1'); + const more=menu.getByRole('button',{name:/更多分享方式|More sharing options/}); + await expect(more).toBeVisible(); + await more.click(); + await expect(page.getByRole('menu',{name:/更多分享方式|More sharing options/})).toBeVisible(); + await save(page,'S12','S12'); + await page.keyboard.press('Escape'); + const publish=menu.getByRole('menuitem').filter({hasText:/链接|复制/}).first(); + await expect(publish).toBeVisible(); + let release!:()=>void; const gate=new Promise(res=>{release=res;}); + await page.route(`**/api/projects/${projectId}/files/index.html/publish-public`,async r=>{if(r.request().method()!=='POST')return r.continue();await gate;await r.fulfill({status:500,json:{error:'ui_audit_mock_failure'}});}); + await publish.click(); + try{await expect(menu.getByRole('progressbar')).toBeVisible();await save(page,'S2','S2');}finally{release();} + await expect(menu.getByRole('status')).toBeVisible(); + await save(page,'S7','S7'); + await page.route(`**/api/projects/${projectId}/files/index.html/publish-public`,async r=>{if(r.request().method()!=='POST')return r.continue();await r.fulfill({status:413,json:{error:{code:'too_large',message:'Content too large'}}});}); + await menu.getByRole('menuitem').filter({hasText:/重试|Retry/}).first().click(); + await expect(menu.getByRole('status')).toContainText(/超过.*MiB.*上限|too large|size limit/i); + await save(page,'S15','S15'); + await page.evaluate(()=>{Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:()=>Promise.resolve()}});}); + const url=`https://example.test/artifact/${projectId}/ui-audit-link`; + await page.route(`**/api/projects/${projectId}/files/index.html/publish-public`,async r=>{if(r.request().method()!=='POST')return r.continue();await r.fulfill({json:{url,slug:'ui-audit-link',fileName:'index.html'}});}); + const retry=menu.getByRole('menuitem').filter({hasText:/重试|Retry/}).first(); + await expect(retry).toBeVisible(); + await retry.click(); + await expect(menu.locator('.chrome-publish-url')).toHaveText(url); + await expect(menu.getByRole('status')).toHaveCount(0); + await save(page,'S3','S3'); + await page.waitForTimeout(1900); + await expect(menu.locator('.chrome-publish-url')).toHaveText(url); + await save(page,'S4','S4'); + await menu.getByRole('button',{name:/关闭|Close/}).click(); + await save(page,'G2-entry','G2-entry'); + await card.click(); + await expect(menu.locator('.chrome-publish-url')).toHaveText(url); + await save(page,'G2','G2'); + await more.click(); + await expect(page.getByRole('menu',{name:/更多分享方式|More sharing options/})).toBeVisible(); + await save(page,'S12','S12'); + await page.keyboard.press('Escape'); + await page.evaluate(()=>{Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:()=>new Promise(resolve=>window.addEventListener('ui-audit-release-copy',()=>resolve(),{once:true}))}});}); + const copy=menu.getByRole('button',{name:/复制链接|Copy share link/}); + await copy.click(); + await expect(menu.getByRole('button',{name:/复制中|Copying/})).toBeVisible(); + await save(page,'S4-C-pending','S4-C-pending'); + await page.evaluate(()=>window.dispatchEvent(new Event('ui-audit-release-copy'))); + await expect(menu.getByRole('button',{name:/已复制|Copied/})).toBeVisible(); + await save(page,'S4-C','S4-C'); + await page.waitForTimeout(2000); + await page.evaluate(()=>{Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:()=>Promise.reject(new Error('clipboard denied by audit fixture'))}});document.execCommand=()=>false;}); + await copy.click(); + await expect(menu.getByRole('status')).toBeVisible(); + await save(page,'S11','S11'); + await page.route(`**/api/projects/${projectId}/files/index.html/publish-public`,async r=>{if(r.request().method()!=='DELETE')return r.continue();await r.fulfill({status:500,json:{error:'ui_audit_mock_stop_failure'}});}); + await menu.getByRole('switch',{name:/链接访问|Link access/}).click(); + await expect(menu.getByRole('status')).toContainText(/停止|关闭|stop/i); + await save(page,'S10','S10'); + await page.route(`**/api/projects/${projectId}/files/index.html/publish-public`,async r=>{if(r.request().method()!=='DELETE')return r.continue();await r.fulfill({json:{slug:'ui-audit-link'}});}); + await menu.getByRole('switch',{name:/链接访问|Link access/}).click(); + await expect(menu.locator('.chrome-publish-url')).toHaveCount(0); + await save(page,'S9','S9'); + let reopen!:()=>void;const reopening=new Promise(res=>{reopen=res;}); + let markRequestStarted!:()=>void;const requestStarted=new Promise(res=>{markRequestStarted=res;}); + await page.route(`**/api/projects/${projectId}/files/index.html/publish-public`,async r=>{if(r.request().method()!=='POST')return r.continue();markRequestStarted();await reopening;await r.fulfill({json:{url,slug:'ui-audit-link',fileName:'index.html'}});}); + await menu.getByRole('menuitem').filter({hasText:/生成并复制链接|Generate and copy|重新开启|Resume/}).first().click(); + try {await requestStarted;await save(page,'S9-R','S9-R');}finally{reopen();} + await expect(menu.locator('.chrome-publish-url')).toHaveText(url); + await page.goto(`/projects/${projectId}/files/index.html`); + const toolbarShare=page.getByTestId('file-workspace').getByRole('button',{name:'分享',exact:true}); + const comments=page.getByTestId('comment-panel-toggle'); + await expect(comments).toBeVisible(); + await expect(toolbarShare).toBeVisible(); + if(await toolbarShare.isDisabled()) await save(page,'E0-pending','E0-pending'); + await expect(toolbarShare).toBeEnabled(); + await save(page,'E0','E0'); + await comments.click(); + await expect(page.getByTestId('comment-side-panel')).toBeVisible(); + await expect(page.getByTestId('comment-side-item')).toHaveCount(0); + await save(page,'R1','R1'); + // Install the external read fixture only AFTER recording R1's real empty state. + // Re-enter the same file to force the production read path without changing daemon comments. + const commentUrl = `**/api/projects/${projectId}/conversations/${conversationId}/comments`; + let auditExcludeOnly=false; + let auditEmptyName=false; + let auditDuplicateAuthor=false; + let auditPalette=false; + const o8Keys='0019,0029,0039,0049,0059,0069,0079,0089,0099,000a,000b,000c,000d,000e,000f,001f,002f,003f,004f,005f,0000,0001,0002,0003,0004,0005,0006,0007,0008,0009'.split(',').map(suffix=>'a'.repeat(60)+suffix); + let auditHandled=false; + await page.route(commentUrl, async route => { + if (route.request().method() !== 'GET') return route.fallback(); + if(auditPalette) return route.fulfill({json:{comments:o8Keys.map((authorKey,index)=>({ + id:`o8-${index}`,projectId,conversationId,filePath:'index.html',elementId:'audit-target',selector:'main h1',label:'标题',text:'审计产物',position:{x:0.25,y:0.25,width:0.5,height:0.1},note:`调色板第 ${index} 色`,status:'open',authorKind:'user',authorKey,authorDisplayName:`色号 ${index}`,createdAt:now-500+index,updatedAt:now-500+index, + }))}}); + if(auditHandled) return route.fulfill({json:{comments:[ + {id:'ui-audit-external-comment',projectId,conversationId,filePath:'index.html',elementId:'audit-target',note:'来自外部访客的审计评论',status:'resolved',authorKind:'user',authorKey:'ui-audit-visitor',createdAt:now-500,updatedAt:Date.now()}, + {id:'ui-audit-own-comment',projectId,conversationId,filePath:'index.html',elementId:'audit-own-target',selector:'main p',label:'Owner 注释区域',text:'Owner 注释区域',position:{x:0.2,y:0.4,width:0.5,height:0.1},note:'Owner 的审计评论',status:'open',createdAt:now-900,updatedAt:now-900}, + ]}}); + if(auditDuplicateAuthor) return route.fulfill({json:{comments:['First keyed comment','Second keyed comment'].map((note,index)=>({ + id:`o5-${index}`,projectId,conversationId,filePath:'index.html',elementId:'audit-target',selector:'main h1',label:'标题',text:'审计产物', + position:{x:0.25,y:0.25,width:0.5,height:0.1},note,status:'open',authorKind:'user',authorKey:'a'.repeat(64),authorDisplayName:'Shared author', + createdAt:now-700+index,updatedAt:now-700+index, + }))}}); + if(auditExcludeOnly) return route.fulfill({json:{comments:[ + {id:'audit-own-current',projectId,conversationId,filePath:'index.html',elementId:'audit-own-target',selector:'main p',label:'Owner 注释区域',text:'Owner 注释区域',position:{x:0.2,y:0.4,width:0.5,height:0.1},note:'自己写的新评论',status:'open',authorMemberId:AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceMemberId,createdAt:Date.now()+1000,updatedAt:Date.now()+1000}, + {id:'audit-older-external',projectId,conversationId,filePath:'index.html',elementId:'audit-target',selector:'main h1',label:'标题',text:'审计产物',position:{x:0.25,y:0.25,width:0.5,height:0.1},note:'已读旧评论',status:'open',authorKind:'user',authorKey:'older-visitor',createdAt:now-900,updatedAt:now-900}, + {id:'audit-other-file',projectId,conversationId,filePath:'other.html',elementId:'other-target',selector:'h1',label:'其他文件',text:'其他文件',position:{x:0.2,y:0.2,width:0.5,height:0.1},note:'其他文件的新评论',status:'open',authorKind:'user',authorKey:'other-file-visitor',createdAt:Date.now()+1000,updatedAt:Date.now()+1000}, + {id:'audit-handled',projectId,conversationId,filePath:'index.html',elementId:'audit-target',selector:'main h1',label:'标题',text:'审计产物',position:{x:0.25,y:0.25,width:0.5,height:0.1},note:'已处理的新评论',status:'resolved',authorKind:'user',authorKey:'handled-visitor',createdAt:Date.now()+1000,updatedAt:Date.now()+1000}, + ]}}); + await route.fulfill({ json: { comments: [{ + id: 'ui-audit-external-comment', projectId, conversationId, filePath: 'index.html', + elementId: 'audit-target', selector: 'main h1', label: '标题', text: '审计产物', + position: { x: 0.25, y: 0.25, width: 0.5, height: 0.1 }, htmlHint: '

审计产物

', + note: '来自外部访客的审计评论', status: 'open', createdAt: now - 500, updatedAt: now - 500, + authorKind: 'user', authorAppUserId: 'ui-audit-visitor', authorDisplayName: auditEmptyName ? '' : '外部访客', authorKey: 'ui-audit-visitor', + }, { + id: 'ui-audit-own-comment', projectId, conversationId, filePath: 'index.html', + elementId: 'audit-own-target', selector: 'main p', label: 'Owner 注释区域', text: 'Owner 注释区域', + position: { x: 0.2, y: 0.4, width: 0.5, height: 0.1 }, htmlHint: '

Owner 注释区域

', + note: 'Owner 的审计评论', status: 'open', createdAt: now - 900, updatedAt: now - 900, + }] } }); + }); + let auditLastReadAt: number | undefined; + let auditCanAcknowledge=false; + await page.route(`**/api/projects/${projectId}/comments/read`,async r=>{ + if(r.request().method()==='PUT'&&!auditCanAcknowledge) return r.fulfill({status:503,json:{error:'ui_audit_read_failure'}}); + if(r.request().method()==='PUT') auditLastReadAt=Date.now(); + await r.fulfill({json:{projectId,...(auditLastReadAt===undefined?{}:{lastReadAt:auditLastReadAt})}}); + }); + await page.reload(); + await expect(page.getByTestId('comment-unread-dot')).toBeVisible(); + await save(page,'C0','C0'); + await save(page,'R2','R2'); + await comments.click(); + await expect(page.getByTestId('comment-side-panel')).toBeVisible(); + await expect(page.getByTestId('comment-unread-dot')).toHaveCount(0); + await page.getByTestId('comment-side-panel').locator('.comment-side-collapse').click(); + await expect(page.getByTestId('comment-side-collapsed-rail')).toBeVisible(); + await expect(page.getByTestId('comment-rail-unread-dot')).toBeVisible(); + await save(page,'R3','R3'); + auditCanAcknowledge=true; + await page.getByTestId('comment-side-collapsed-rail').click(); + await expect(page.getByTestId('comment-side-panel')).toBeVisible(); + await page.getByTestId('comment-side-panel').locator('.comment-side-collapse').click(); + await expect(page.getByTestId('comment-side-collapsed-rail')).toBeVisible(); + await expect(page.getByTestId('comment-rail-unread-dot')).toHaveCount(0); + await expect(page.getByTestId('comment-unread-dot')).toHaveCount(0); + await save(page,'R4','R4'); + await page.getByTestId('comment-side-collapsed-rail').click(); + await expect(page.getByTestId('comment-side-panel')).toBeVisible(); + const externalComment = page.getByTestId('comment-side-item').filter({ hasText: '来自外部访客的审计评论' }); + await expect(externalComment).toHaveCount(1); + await expect(externalComment).toContainText('外部访客'); + await save(page,'K6','K6'); + await externalComment.getByRole('button',{name:/选择|Select/}).click(); + const sendToChat = page.getByTestId('comment-side-send-claude'); + await expect(sendToChat).toBeVisible(); + await expect(sendToChat).toBeEnabled(); + await save(page,'K7','K7'); + // OP4 is the Owner permission slice of this same selected external-comment state. + await save(page,'OP4','OP4'); + await page.getByTestId('comment-side-selectbar').getByRole('button',{name:/清除|Clear/}).click(); + await expect(sendToChat).toHaveCount(0); + await externalComment.click(); + const externalPopover=page.getByTestId('comment-popover'); + await expect(externalPopover).toBeVisible(); + await expect(externalPopover).toContainText('来自外部访客的审计评论'); + await save(page,'OP2','OP2'); + // Cross-end O3: OD half of an external visitor comment; Vela half is required before completion. + await save(page,'O3-OD','O3-OD'); + await page.reload(); + if (!(await page.getByTestId('comment-side-panel').isVisible())) await comments.click(); + await expect(page.getByTestId('comment-side-panel')).toBeVisible(); + const ownComment=page.getByTestId('comment-side-item').filter({hasText:'Owner 的审计评论'}); + await expect(ownComment).toHaveCount(1); + await ownComment.click(); + await expect(page.getByTestId('comment-popover-input')).toHaveValue('Owner 的审计评论'); + await save(page,'OP1','OP1'); + // Cross-end O2: OD half of the same owner-authored comment; not a combined-state frame. + await save(page,'O2-OD','O2-OD'); + await save(page,'O6-OD','O6-OD',{ownerComment:'Owner 的审计评论',mockProvenance:'GET-only local fixture; not actual share-page delivery'}); + // O4 replacement only: an empty authorDisplayName models an abnormal legacy + // read. Normal writes snapshot name || email on the server (UI-028 correction); + // this screenshot is never O4 target-state or real-account evidence. + auditEmptyName=true; + await page.reload(); + if (!(await page.getByTestId('comment-side-panel').isVisible())) await comments.click(); + const unnamedComment=page.getByTestId('comment-side-item').filter({hasText:'来自外部访客的审计评论'}); + await expect(unnamedComment).toHaveCount(1); + await unnamedComment.click(); + await save(page,'O4-OD','O4-OD'); + auditEmptyName=false; + auditDuplicateAuthor=true; + await page.reload(); + if (!(await page.getByTestId('comment-side-panel').isVisible())) await comments.click(); + const keyedComments=page.getByTestId('comment-side-item'); + await expect(keyedComments).toHaveCount(2); + const avatarColors=await keyedComments.locator('.comment-side-avatar').evaluateAll(avatars=>avatars.map(node=>getComputedStyle(node).backgroundColor)); + expect(avatarColors).toHaveLength(2); + expect(avatarColors[0]).toBe(avatarColors[1]); + const clientPins=page.locator('.comment-saved-pin'); + await expect(clientPins).toHaveCount(2); + const pinColors=await clientPins.evaluateAll(pins=>pins.map(node=>getComputedStyle(node).backgroundColor)); + expect(pinColors).toEqual(['rgb(217, 106, 70)','rgb(217, 106, 70)']); + await save(page,'O5-OD','O5-OD',{avatarColors,pinColors,authorKey:'a'.repeat(64)}); + auditDuplicateAuthor=false; + auditPalette=true; + await page.setViewportSize({width:1440,height:3500}); + await page.reload(); + if (!(await page.getByTestId('comment-side-panel').isVisible())) await comments.click(); + const swatchRows=page.getByTestId('comment-side-item'); + await expect(swatchRows).toHaveCount(30); + const displayedIndexes=await swatchRows.evaluateAll(rows=>rows.map(row=>{ + const match=row.textContent?.match(/调色板第 (\d+) 色/); + if (!match) throw new Error('Missing O8 displayed row index'); + return Number(match[1]); + })); + expect(displayedIndexes).toEqual(Array.from({length:30},(_,i)=>29-i)); + const palette=await swatchRows.evaluateAll(rows=>rows.map(row=>{ + const match=row.textContent?.match(/调色板第 (\d+) 色/); + const avatar=row.querySelector('.comment-side-avatar'); + if (!match||!avatar) throw new Error('Missing O8 author palette row/avatar'); + return {index:Number(match[1]),color:getComputedStyle(avatar).backgroundColor}; + }).sort((a,b)=>a.index-b.index)); + expect(new Set(palette.map(item=>item.color)).size).toBe(30); + await save(page,'O8-OD','O8-OD',{palette,keys:o8Keys,displayedIndexes,apiInputOrder:'ascending'}); + auditPalette=false; + await page.setViewportSize({width:1440,height:900}); + auditHandled=true; + await page.reload(); + if (!(await page.getByTestId('comment-side-panel').isVisible())) await comments.click(); + await expect(page.getByTestId('comment-side-item')).toHaveCount(1); + await expect(page.getByTestId('comment-side-item')).toContainText('Owner 的审计评论'); + await expect(page.getByText('来自外部访客的审计评论')).toHaveCount(0); + await save(page,'OP5','OP5',{fixtureExternalStatus:'resolved',mutation:'none; GET-only after state'}); + auditHandled=false; + await page.reload(); + if (!(await page.getByTestId('comment-side-panel').isVisible())) await comments.click(); + await expect(page.getByTestId('comment-side-panel')).toBeVisible(); + // Prior publish/unpublish actions are local mocks; seed the authoritative + // per-file GET projection that makes the owner banner a real rendered UI state. + await page.route(`**/api/projects/${projectId}/files/index.html/publish-public`,r=>r.request().method()==='GET' + ?r.fulfill({json:{status:'active',freshness:'current',publication:{url,slug:'ui-audit-link',fileName:'index.html'}}}) + :r.fallback()); + await page.route(`**/api/projects/${projectId}/comment-sync-state*`,r=>r.request().method()==='GET'?r.fulfill({json:{pending:0,sessionMissing:false,lastError:'backfill_failed',shareStopped:false,backfill:{state:'failed',filePath:'index.html',publicationRevision:'ui-audit-publication-revision',retryable:true}}}):r.fallback()); + await page.reload(); + const syncShareButton=page.getByTestId('file-workspace').getByRole('button',{name:'分享',exact:true}); + await expect(syncShareButton).toBeEnabled(); + const failedBackfillRead=page.waitForResponse(r=>r.url().includes(`/api/projects/${projectId}/comment-sync-state`)&&r.request().method()==='GET'&&r.ok()); + await syncShareButton.click(); + await expect(page.locator('.share-menu-popover[role="menu"]')).toBeVisible(); + await failedBackfillRead; + const backfillBanner=page.getByRole('status').filter({hasText:/已有评论还没同步|评论.*同步/}).first(); + await expect(backfillBanner).toBeVisible(); + await expect(backfillBanner).toContainText('已有评论暂未同步,访客暂时看不到。正在后台自动重试。'); + await save(page,'K2','K2',{backfill:{state:'failed',retryable:true,filePath:'index.html'},provenance:'GET-only local UI fixture'}); + // This same failed-backfill response follows the scripted stop → re-enable (S9-R). + await save(page,'K5','K5'); + await page.unroute(`**/api/projects/${projectId}/comment-sync-state*`); + // K8 is not captured here: ShareTab mounts this banner with backfillOnly, + // which intentionally suppresses sessionMissing; its owner-wide banner has + // a different host surface and must not be represented by this fixture. + auditExcludeOnly=true; + await page.reload(); + await expect(page.getByTestId('comment-panel-toggle')).toHaveAttribute('aria-label','评论 (2)'); + await expect(page.getByTestId('comment-unread-dot')).toHaveCount(0); + await save(page,'R6','R6'); + await page.evaluate(()=>window.dispatchEvent(new Event('focus'))); + await page.route(`**/api/projects/${projectId}/share-state`,r=>r.fulfill({json:{projectId,hasEverShared:true,bindingExists:true,publications:[{sourceFilePath:'index.html',slug:'ui-audit-link',status:'active'}]}})); + await page.getByTestId('workspace-tabs-dropdown-trigger').click(); + const row=page.getByTestId('workspace-tabs-dropdown').locator('.workspace-tabs-dropdown__row').filter({hasText:'UI audit isolated fixture'}).first(); + await expect(row).toBeVisible(); + await row.getByTestId('workspace-tabs-dropdown-row-more').click(); + await page.getByTestId('workspace-tabs-dropdown-row-menu').getByRole('menuitem',{name:/删除|Delete/}).click(); + const confirm=page.getByTestId('project-delete-confirm-dialog'); + await expect(confirm).toBeVisible(); + await expect(confirm).toContainText(/分享|链接/); + await save(page,'S14','S14'); + await page.getByTestId('project-delete-confirm-cancel').click(); + await expect(confirm).toHaveCount(0); + // This intercept simulates the daemon's durable pending-stop outcome; it never sends DELETE. + await page.route(`**/api/projects/${projectId}`,r=>r.request().method()==='DELETE'?r.fulfill({json:{shareResiduals:[{filePath:'index.html',slug:'ui-audit-link',retrying:false}]}}):r.fallback()); + await page.getByTestId('workspace-tabs-dropdown-trigger').click(); + const deleteRow=page.getByTestId('workspace-tabs-dropdown').locator('.workspace-tabs-dropdown__row').filter({hasText:'UI audit isolated fixture'}).first(); + await deleteRow.getByTestId('workspace-tabs-dropdown-row-more').click(); + await page.getByTestId('workspace-tabs-dropdown-row-menu').getByRole('menuitem',{name:/删除|Delete/}).click(); + await page.getByTestId('project-delete-confirm-accept').click(); + await expect(page.getByRole('alert').filter({hasText:'ui-audit-link'})).toBeVisible(); + await save(page,'S14-ERR','S14-ERR'); +}); +test('capture updated owner G1 portal and S12 deployment submenu',async({page})=>{ + await page.addInitScript(()=>{localStorage.setItem('open-design:locale','zh-CN');localStorage.setItem('open-design:locale-source','manual');}); + await mockAmrPersonalWorkspace(page); + await page.route('**/api/projects/*/workspace-scope',async r=>{const id=new URL(r.request().url()).pathname.match(/\/api\/projects\/([^/]+)/)?.[1];await r.fulfill({json:{scope:{kind:'personal',projectId:id,workspaceId:AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId,visibility:'personal',context:AMR_PERSONAL_WORKSPACE_CONTEXT}}});}); + const projectId='ui-audit-owner-overlay-'+Date.now(); + const response=await page.request.post('/api/projects',{data:{id:projectId,name:'Owner overlay capture fixture',skillId:null,designSystemId:null,metadata:{kind:'prototype',nameSource:'user'}}}); + expect(response.ok(),await response.text()).toBeTruthy(); + const content='

Owner overlay fixture

'; + const file=await page.request.post('/api/projects/'+projectId+'/files',{data:{name:'index.html',content,artifactManifest:{version:1,kind:'html',title:'index.html',entry:'index.html',renderer:'html',exports:['html']}}}); + expect(file.ok(),await file.text()).toBeTruthy(); + await page.route(`**/api/projects/${projectId}/files/index.html/share-plan`,r=>r.request().method()==="POST"?r.fulfill({json:{fileCount:1,totalBytes:content.length,exceedsSizeLimit:false,exclusions:[]}}):r.fallback()); + await page.goto('/projects/'+projectId+'/files/index.html'); + const share=page.getByTestId('file-workspace').getByRole('button',{name:'分享',exact:true}); + await expect(share).toBeVisible(); + await share.click(); + const menu=page.locator('.share-menu-popover[role="menu"]'); + await expect(menu).toBeVisible(); + const geometry=await menu.evaluate(el=>{const r=el.getBoundingClientRect();let node:Element|null=el;while(node&&node!==document.body)node=node.parentElement;const parent=el.parentElement;return {portal:node===document.body,rect:{x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom},viewport:{width:innerWidth,height:innerHeight},dialog:el.closest('[role="dialog"]')?.getBoundingClientRect().toJSON()??null,parentTag:parent?.tagName.toLowerCase(),parentClass:parent?.getAttribute('class'),parentOverflow:getComputedStyle(parent!).overflow,zIndex:getComputedStyle(el).zIndex}}); + expect(geometry.portal,'share popup must be in a body portal, outside any dialog').toBe(true); + expect(geometry.dialog,'share popup must not be trapped inside the right dialog').toBeNull(); + expect(geometry.rect.x+geometry.rect.width).toBeLessThanOrEqual(geometry.viewport.width); + expect(await page.getByRole('alert').filter({hasText:/could not be checked|未能检查|无法检查/}).count()).toBe(0); + await save(page,'G1','G1',{entry:'file-workspace top-right share button',geometry}); + const more=menu.getByRole('button',{name:/更多分享方式|More sharing options/}); + await expect(more).toBeVisible(); + await more.click(); + const submenu=page.getByRole('menu',{name:/更多分享方式|More sharing options/}); + await expect(submenu).toBeVisible(); + const bounds=await submenu.evaluate(el=>{const r=el.getBoundingClientRect();let node:Element|null=el;while(node&&node!==document.body)node=node.parentElement;const parent=el.parentElement;return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom,viewport:{width:innerWidth,height:innerHeight},parentOverflow:getComputedStyle(parent!).overflow,portal:node===document.body,zIndex:getComputedStyle(el).zIndex}}); + expect(bounds.portal,'deployment submenu must be in a body portal').toBe(true); + expect(bounds.right<=geometry.rect.x||bounds.x>=geometry.rect.x+geometry.rect.width,'submenu must sit outside the share panel').toBe(true); + expect(await submenu.evaluate(el=>{const r=el.getBoundingClientRect();return el.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2));}),'submenu must be hit-testable in front of the page').toBe(true); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.right).toBeLessThanOrEqual(bounds.viewport.width); + expect(bounds.y).toBeGreaterThanOrEqual(0); + expect(bounds.bottom).toBeLessThanOrEqual(bounds.viewport.height); + await save(page,'S12','S12',{geometry:bounds}); +}); +test('capture first-export share guide and ever-shared exclusion P1 EX0',async({page})=>{ + await page.addInitScript(()=>{localStorage.setItem('open-design:locale','zh-CN');localStorage.setItem('open-design:locale-source','manual');}); + await mockAmrPersonalWorkspace(page); + await page.route('**/api/integrations/vela/status*',r=>r.fulfill({json:{loggedIn:true,profile:'test',configPath:'',user:{id:'ui-audit-export-account',email:'fixture@example.invalid'}}})); + await page.route('**/api/projects/*/workspace-scope',async r=>{const id=new URL(r.request().url()).pathname.match(/\/api\/projects\/([^/]+)/)?.[1];await r.fulfill({json:{scope:{kind:'personal',projectId:id,workspaceId:AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId,visibility:'personal',context:AMR_PERSONAL_WORKSPACE_CONTEXT}}});}); + const projectId=`ui-audit-export-${Date.now()}`; + const response=await page.request.post('/api/projects',{data:{id:projectId,name:'UI audit isolated export fixture',skillId:null,designSystemId:null,metadata:{kind:'prototype',nameSource:'user'}}}); + const content='

待导出的审计产物

'; + expect(response.ok(),await response.text()).toBeTruthy(); + const file=await page.request.post(`/api/projects/${projectId}/files`,{data:{name:'index.html',content,artifactManifest:{version:1,kind:'html',title:'index.html',entry:'index.html',renderer:'html',exports:['html']}}}); + expect(file.ok(),await file.text()).toBeTruthy(); + let everShared=false; + await page.route(`/api/projects/${projectId}/share-state`,r=>r.fulfill({json:{projectId,hasEverShared:everShared,bindingExists:everShared,publications:[]}})); + await page.clock.install(); + const firstHistory=page.waitForResponse(r=>r.url().includes(`/api/projects/${projectId}/share-state`)&&r.ok()); + await page.goto(`/projects/${projectId}/files/index.html`); + await firstHistory; + const exportButton=page.getByTestId('file-workspace').getByRole('button',{name:'导出',exact:true}); + await expect(exportButton).toBeEnabled(); + await exportButton.click(); + const htmlExport=page.locator('.chrome-unified-popover').getByRole('menuitem',{name:/HTML/i}); + await Promise.all([page.waitForEvent('download'),htmlExport.click()]); + await expect(page.getByRole('status',{name:'导出完成'})).toBeVisible(); + await save(page,'P1','P1'); + const guide=page.getByRole('status',{name:'导出完成'}); + await expect(guide).toBeVisible(); + await page.mouse.move(0,0); + await page.clock.fastForward(11_000); + await expect(guide).toHaveCount(0); + await exportButton.click(); + await Promise.all([page.waitForEvent('download'),htmlExport.click()]); + await expect(guide).toBeVisible(); + await guide.getByRole('button',{name:'不再提示'}).click(); + await expect(guide).toHaveCount(0); + const suppressedHistory=page.waitForResponse(r=>r.url().includes(`/api/projects/${projectId}/share-state`)&&r.ok()); + await page.reload(); + await suppressedHistory; + await expect(exportButton).toBeEnabled(); + await exportButton.click(); + await Promise.all([page.waitForEvent('download'),htmlExport.click()]); + await expect(guide).toHaveCount(0); + // Reset only this test user's preference so EX0 is independently gated by history, not by neverShow. + await page.evaluate(()=>localStorage.removeItem('od:after-export-share-guide:v1:ui-audit-export-account')); + // A prior binding includes stopped publications. Never call a real public-publish route. + everShared=true; + const secondHistory=page.waitForResponse(r=>r.url().includes(`/api/projects/${projectId}/share-state`)&&r.ok()); + await page.reload(); + await secondHistory; + await expect(exportButton).toBeEnabled(); + await exportButton.click(); + await Promise.all([page.waitForEvent('download'),htmlExport.click()]); + await expect(page.getByRole('status',{name:'导出完成'})).toHaveCount(0); + await save(page,'EX0','EX0'); +}); + +test('capture signed-out share shell S0',async({page})=>{ + await page.addInitScript(()=>{localStorage.setItem('open-design:locale','zh-CN');localStorage.setItem('open-design:locale-source','manual');}); + await page.route('**/api/integrations/vela/status*',r=>r.fulfill({json:{loggedIn:false,profile:null,configPath:'',user:null}})); + await page.route('**/api/projects/*/workspace-scope',r=>{const id=new URL(r.request().url()).pathname.match(/\/api\/projects\/([^/]+)/)?.[1];return r.fulfill({json:{scope:{kind:'unbound',projectId:id,workspaceId:null,context:null}}});}); + const projectId=`ui-audit-signed-out-${Date.now()}`; + const response=await page.request.post('/api/projects',{data:{id:projectId,name:'UI audit isolated signed-out fixture',skillId:null,designSystemId:null,metadata:{kind:'prototype',nameSource:'user'}}}); + expect(response.ok(),await response.text()).toBeTruthy(); + const file=await page.request.post(`/api/projects/${projectId}/files`,{data:{name:'index.html',content:'

审计产物

',artifactManifest:{version:1,kind:'html',title:'index.html',entry:'index.html',renderer:'html',exports:['html']}}}); + expect(file.ok(),await file.text()).toBeTruthy(); + await page.goto(`/projects/${projectId}/files/index.html`); + const share=page.getByTestId('file-workspace').getByRole('button',{name:'分享',exact:true}); + await expect(share).toBeEnabled(); + await share.click(); + const menu=page.locator('.share-menu-popover[role="menu"]'); + await expect(menu).toBeVisible(); + await expect(menu.getByRole('switch',{name:/链接访问|Link access/})).toHaveCount(0); + await expect(menu.getByText(/快速分享|分享至社交|Quick share|Social share/)).toHaveCount(0); + await save(page,'S0','S0'); + // S13: a prior binding exists in the API projection, but session identity is + // gone. Capture the real empty fallback; it is NOT the designed shared-link state. + await page.route(`**/api/projects/${projectId}/share-state`,r=>r.fulfill({json:{projectId,hasEverShared:true,bindingExists:true,publications:[{sourceFilePath:'index.html',slug:'ui-audit-prior-link',status:'active'}]}})); + await page.reload(); + const signedOutShare=page.getByTestId('file-workspace').getByRole('button',{name:'分享',exact:true}); + await expect(signedOutShare).toBeEnabled(); + await signedOutShare.click(); + const lostSessionMenu=page.locator('.share-menu-popover[role="menu"]'); + await expect(lostSessionMenu).toBeVisible(); + await expect(lostSessionMenu.locator('.chrome-publish-url')).toHaveCount(0); + await expect(lostSessionMenu.getByRole('switch',{name:/链接访问|Link access/})).toHaveCount(0); + await save(page,'S13','S13-empty-shell'); +}); + +// S1-T/S1-T2/S4-T: team identity is a scoped GET projection on a new local +// project. Intercept public POST; never upload to the shared service. +test('capture isolated team share menu and access choices',async({page})=>{ + test.setTimeout(120_000); + const team={...AMR_PERSONAL_WORKSPACE_CONTEXT,workspaceId:'ws-ui-audit-team',workspaceName:'UI audit team',workspaceType:'team' as const,workspaceMemberId:'mem-ui-audit-team',seatSummary:{seatLimit:5,usedSeats:1,availableSeats:4,isSeatFull:false},teamId:'team-ui-audit',teamName:'UI audit team',role:'owner' as const}; + await page.addInitScript(()=>{localStorage.setItem('open-design:locale','zh-CN');localStorage.setItem('open-design:locale-source','manual');}); + await mockAmrPersonalWorkspace(page); + await page.route('**/api/workspace/directory',r=>r.fulfill({json:{items:[team],activeWorkspaceId:team.workspaceId}})); + await page.route('**/api/workspace/context',r=>r.fulfill({json:{context:team}})); + const projectId=`ui-audit-team-${Date.now()}`; + await page.route('**/api/projects/*/workspace-scope',r=>r.fulfill({json:{scope:{kind:'team',projectId,workspaceId:team.workspaceId,teamId:team.teamId,visibility:'private',context:team}}})); + const create=await page.request.post('/api/projects',{data:{id:projectId,name:'Team UI audit local fixture',skillId:null,designSystemId:null,metadata:{kind:'prototype',nameSource:'user'}}}); + expect(create.ok(),await create.text()).toBeTruthy(); + const {conversationId}=await create.json() as {conversationId:string}; + const content='

Team audit fixture

'; + const file=await page.request.post(`/api/projects/${projectId}/files`,{data:{name:'index.html',content,artifactManifest:{version:1,kind:'html',title:'index.html',entry:'index.html',renderer:'html',exports:['html']}}}); + expect(file.ok(),await file.text()).toBeTruthy(); + await page.route(`**/api/projects/${projectId}/files/index.html/share-plan`,r=>r.request().method()==='POST'?r.fulfill({json:{fileCount:1,totalBytes:content.length,exceedsSizeLimit:false,exclusions:[]}}):r.fallback()); + const projectResponse=await page.request.get(`/api/projects/${projectId}`); + expect(projectResponse.ok(),await projectResponse.text()).toBeTruthy(); + const localProject=(await projectResponse.json() as {project:Record}).project; + const teamProject={...localProject,workspaceId:team.workspaceId,workspaceVisibility:'private'}; + await page.route(`**/api/workspaces/${team.workspaceId}/projects?*`,r=>r.fulfill({json:{projects:[{project:teamProject,workspaceId:team.workspaceId,visibility:'private',resourceState:'active',createdByWorkspaceMemberId:team.workspaceMemberId,updatedByWorkspaceMemberId:team.workspaceMemberId,currentUserAccess:{canOpen:true,canRename:true,canDelete:true,canDuplicate:true,canMoveToTeam:false,canMoveToPersonal:true,canExport:true,canSendTo:true,canRestoreVersion:true}}]}})); + await page.route(`**/api/projects/${projectId}`,r=>r.request().method()==='GET'?r.fulfill({json:{project:teamProject}}):r.fallback()); + await page.goto(`/projects/${projectId}/conversations/${conversationId}`); + const fileRow=page.getByTestId('file-workspace').getByText('index.html',{exact:true}).first(); + await expect(fileRow).toBeVisible(); + await fileRow.click(); + const share=page.getByTestId('file-workspace').getByRole('button',{name:'分享',exact:true}); + await expect(share).toBeEnabled(); + await share.click(); + const menu=page.locator('.share-menu-popover[role="menu"]'); + await expect(menu).toBeVisible(); + await expect(menu.getByRole('switch',{name:/链接访问|Link access/})).toBeVisible(); + const access=menu.locator('.chrome-access-trigger'); + await expect(access).toBeVisible(); + await save(page,'S1-T','S1-T',{mockWorkspaceType:team.workspaceType}); + await access.click(); + await expect(menu.getByRole('listbox')).toBeVisible(); + await expect(menu.getByRole('option')).toHaveCount(2); + await save(page,'S1-T2','S1-T2',{mockWorkspaceType:team.workspaceType}); + await page.keyboard.press('Escape'); + const url=`https://example.test/artifact/${projectId}/team-link`; + await page.route(`**/api/projects/${projectId}/files/index.html/publish-public`,r=>r.request().method()==='POST'?r.fulfill({json:{url,slug:'team-link',fileName:'index.html'}}):r.fallback()); + await menu.getByRole('menuitem').filter({hasText:/链接|复制/}).first().click(); + await expect(menu.locator('.chrome-publish-url')).toHaveText(url); + await save(page,'S4-T','S4-T',{mockWorkspaceType:team.workspaceType}); + const memberComment={id:'o1-member',projectId,conversationId,filePath:'index.html',elementId:'team-title',selector:'main h1',label:'Team audit fixture',text:'Team audit fixture',position:{x:0.2,y:0.2,width:0.6,height:0.1},note:'来自团队成员的评论',status:'open',authorKind:'member',authorMemberId:'mem-other-team-member',authorDisplayName:'协作成员',authorKey:'b'.repeat(64),createdAt:Date.now()-60000,updatedAt:Date.now()-60000}; + await page.route(`**/api/projects/${projectId}/conversations/${conversationId}/comments`,r=>r.request().method()==='GET'?r.fulfill({json:{comments:[memberComment]}}):r.fallback()); + await page.goto(`/projects/${projectId}/files/index.html`); + const comments=page.getByTestId('comment-panel-toggle'); + await expect(comments).toBeVisible(); + await comments.click(); + const memberRow=page.getByTestId('comment-side-item').filter({hasText:'来自团队成员的评论'}); + await expect(memberRow).toBeVisible(); + await expect(memberRow).toContainText('协作成员'); + await save(page,'O1-OD','O1-OD',{authorKey:memberComment.authorKey,mockWorkspaceType:team.workspaceType}); + const nonOwner={...team,role:'member' as const,permissions:{...team.permissions,canManageMembers:false,canManageBilling:false}}; + await page.route('**/api/workspace/context',r=>r.fulfill({json:{context:nonOwner}})); + await page.route('**/api/projects/*/workspace-scope',r=>r.fulfill({json:{scope:{kind:'team',projectId,workspaceId:team.workspaceId,teamId:team.teamId,visibility:'private',context:nonOwner}}})); + await page.route(`**/api/projects/${projectId}/conversations/${conversationId}/comments`,r=>r.request().method()==='GET'?r.fulfill({json:{comments:[{...memberComment,id:'op3-external',authorKind:'user',authorMemberId:undefined,authorDisplayName:'外部访客',authorKey:'c'.repeat(64),note:'团队成员查看外部评论'}]}}):r.fallback()); + const memberContextResponse=page.waitForResponse(r=>r.url().endsWith('/api/workspace/context')&&r.ok(),{timeout:10000}); + await page.reload(); + const memberContext=await (await memberContextResponse).json() as {context:{role:string}}; + expect(memberContext.context.role).toBe('member'); + if (!(await page.getByTestId('comment-side-panel').isVisible())) await comments.click(); + const externalRow=page.getByTestId('comment-side-item').filter({hasText:'团队成员查看外部评论'}); + await expect(externalRow).toBeVisible(); + await externalRow.click(); + const readonly=page.getByTestId('comment-popover'); + await expect(readonly).toContainText('分享页评论不可编辑'); + await expect(page.getByTestId('comment-popover-input')).not.toBeEditable(); + await expect(page.getByTestId('comment-popover-input')).toHaveAttribute('readonly',''); + await save(page,'OP3','OP3',{mockWorkspaceRole:'member',authorKind:'user'}); + // Versioned team comments pass through the actual live anchor resolver. + await page.route(`**/api/projects/${projectId}/collab/status`,r=>r.fulfill({json:{publishedVersion:2,materializedVersion:2,awaitingFirstMaterialization:false,syncState:'synced',ownerMemberId:'mem-other-team-owner',ownerDisplayName:'Team Owner',ownerRole:'owner',contentTransferState:null}})); + let newCommentArrived=false; + const driftComments=[ + {...memberComment,id:'d1-reanchored',note:'跨版本仍在的标题锚点',anchoredVersion:1}, + {...memberComment,id:'d2-lost',elementId:'removed-unique-target',selector:'#removed-unique-target',label:'被删除的区块',text:'被删除的区块',note:'跨版本已删除的锚点',position:{x:70,y:120,width:160,height:40},anchoredVersion:1}, + ]; + await page.route(`**/api/projects/${projectId}/conversations/${conversationId}/comments`,r=>r.request().method()==='GET'?r.fulfill({json:{comments:newCommentArrived?[...driftComments,{...memberComment,id:'r5-arrived',note:'新到达的队友评论',createdAt:Date.now(),updatedAt:Date.now()}]:driftComments}}):r.fallback()); + await page.route(`**/api/projects/${projectId}/conversations/${conversationId}/comments/lost-anchors`,r=>r.fulfill({json:{updated:0}})); + await page.reload(); + if (!(await page.getByTestId('comment-side-panel').isVisible())) await comments.click(); + const reanchor=page.locator('[data-anchor-state="reanchored"]'); + const lost=page.locator('[data-anchor-state="lost"]'); + await expect(reanchor.first()).toBeVisible(); + await expect(lost.first()).toBeVisible(); + await expect(reanchor.first().locator('button')).toHaveAttribute('title',/基于旧版本/); + await expect(lost.first().locator('button')).toHaveAttribute('title',/锚点已丢失/); + const d1=page.getByTestId('comment-side-item').filter({hasText:'跨版本仍在的标题锚点'}); + await d1.click(); + await save(page,'D1','D1',{publishedVersion:2,anchoredVersion:1,anchorState:'reanchored',mockWorkspaceRole:'member'}); + const d2=page.getByTestId('comment-side-item').filter({hasText:'跨版本已删除的锚点'}); + await d2.click(); + await save(page,'D2','D2',{publishedVersion:2,anchoredVersion:1,anchorState:'lost',mockWorkspaceRole:'member'}); + newCommentArrived=true; + await page.evaluate(()=>document.dispatchEvent(new Event('visibilitychange'))); + const arrived=page.getByTestId('comment-side-item').filter({hasText:'新到达的队友评论'}); + await expect(arrived).toBeVisible({timeout:11000}); + await expect(page.getByTestId('comment-side-item')).toHaveCount(3); + await save(page,'R5','R5',{mockWorkspaceRole:'member',trigger:'visibilitychange',withoutNavigation:true}); +}); + +// O7 paired time proof: yesterday 22:00 at today 00:10 is 2h10 elapsed. +test('capture OD O7 yesterday boundary',async({page})=>{ + test.setTimeout(70_000); + await page.addInitScript(()=>{localStorage.setItem('open-design:locale','zh-CN');localStorage.setItem('open-design:locale-source','manual');}); + await mockAmrPersonalWorkspace(page); + const projectId=`ui-audit-time-${Date.now()}`; + await page.route('**/api/projects/*/workspace-scope',r=>r.fulfill({json:{scope:{kind:'personal',projectId,workspaceId:AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId,visibility:'personal',context:AMR_PERSONAL_WORKSPACE_CONTEXT}}})); + const create=await page.request.post('/api/projects',{data:{id:projectId,name:'O7 isolated time fixture',skillId:null,designSystemId:null,metadata:{kind:'prototype',nameSource:'user'}}}); + expect(create.ok(),await create.text()).toBeTruthy(); + const {conversationId}=await create.json() as {conversationId:string}; + const file=await page.request.post(`/api/projects/${projectId}/files`,{data:{name:'index.html',content:'

时钟测试

',artifactManifest:{version:1,kind:'html',title:'index.html',entry:'index.html',renderer:'html',exports:['html']}}}); + expect(file.ok(),await file.text()).toBeTruthy(); + const now=new Date(2026,8,24,0,10), prior=new Date(2026,8,23,22,0).getTime(); + await page.clock.install({time:now}); + await page.route(`**/api/projects/${projectId}/conversations/${conversationId}/comments`,r=>r.request().method()==='GET'?r.fulfill({json:{comments:[{id:'o7-yesterday',projectId,conversationId,filePath:'index.html',elementId:'audit-target',selector:'main h1',label:'时钟测试',text:'时钟测试',position:{x:0.2,y:0.2,width:0.6,height:0.1},note:'跨午夜的评论',status:'open',authorKind:'user',authorKey:'o7-timestamp',authorDisplayName:'时钟访客',createdAt:prior,updatedAt:prior}]}}):r.fallback()); + await page.goto(`/projects/${projectId}/files/index.html`); + await page.getByTestId('comment-panel-toggle').click(); + const row=page.getByTestId('comment-side-item').filter({hasText:'跨午夜的评论'}); + await expect(row).toBeVisible(); + await expect(row).toContainText('昨天'); + await save(page,'O7-OD','O7-OD',{frozenNow:now.toISOString(),commentCreatedAt:new Date(prior).toISOString(),displayedTime:'昨天'}); +}); + +// D4: a comment on the second deck slide remains in the sidebar while the +// first slide is active; navigating to its slide makes the real pin visible. +test('capture D4 non-current slide label and investigate missing pin',async({page})=>{ + test.setTimeout(90_000); + await page.addInitScript(()=>{localStorage.setItem('open-design:locale','zh-CN');localStorage.setItem('open-design:locale-source','manual');}); + await mockAmrPersonalWorkspace(page); + const projectId=`ui-audit-deck-${Date.now()}`; + const create=await page.request.post('/api/projects',{data:{id:projectId,name:'Audit two slide deck',skillId:null,designSystemId:null,metadata:{kind:'deck',nameSource:'user'}}}); + expect(create.ok(),await create.text()).toBeTruthy(); + const {conversationId}=await create.json() as {conversationId:string}; + const content='

第一页

'; + const file=await page.request.post(`/api/projects/${projectId}/files`,{data:{name:'deck.html',content,artifactManifest:{version:1,kind:'deck',title:'deck.html',entry:'deck.html',renderer:'deck-html',exports:['html','pdf']}}}); + expect(file.ok(),await file.text()).toBeTruthy(); + const deckComment={id:'d4-second',projectId,conversationId,filePath:'deck.html',elementId:'deck-second-title',selector:'h1[data-od-id="deck-second-title"]',label:'第二页评论目标',text:'第二页评论目标',slideIndex:1,position:{x:120,y:140,width:300,height:70},note:'第二页的评论',status:'open',createdAt:Date.now()-30000,updatedAt:Date.now()-30000}; + await page.route(`**/api/projects/${projectId}/conversations/${conversationId}/comments`,r=>r.request().method()==='GET'?r.fulfill({json:{comments:[deckComment]}}):r.fallback()); + await page.goto(`/projects/${projectId}/files/deck.html`); + const comments=page.getByTestId('comment-panel-toggle'); + await expect(comments).toBeVisible(); + await comments.click(); + const row=page.getByTestId('comment-side-item').filter({hasText:'第二页的评论'}); + await expect(row.locator('.comment-side-slide')).toHaveText('第 2 / 2 页'); + await expect(page.getByTestId('comment-saved-marker-deck-second-title')).toHaveCount(0); + await save(page,'D4','D4-label-only',{slideIndex:1,activeSlideIndex:0,slideLabel:await row.locator('.comment-side-slide').innerText(),pinVisible:false}); + await page.getByRole('button',{name:'收起评论'}).click({timeout:5000}); + await page.getByTestId('artifact-preview-frame').contentFrame().locator('body').press('ArrowRight'); + await expect(page.getByRole('region',{name:'演讲者备注'})).toContainText('第 2 / 2 页'); + await expect(page.getByTestId('comment-saved-marker-deck-second-title')).toHaveCount(0); +}); diff --git a/packages/contracts/src/api/collab.ts b/packages/contracts/src/api/collab.ts index f8f70465a3..f5d8eec0cf 100644 --- a/packages/contracts/src/api/collab.ts +++ b/packages/contracts/src/api/collab.ts @@ -1073,6 +1073,8 @@ export interface CollabCloudComment { seq: number; note: string; filePath: string; + /** Server-asserted alias identity for public-file relays; clients do not supply it. */ + publicationSlug?: string; elementId: string; selector: string; label: string; diff --git a/packages/contracts/src/api/share.ts b/packages/contracts/src/api/share.ts index 83ef95db00..6e26661a11 100644 --- a/packages/contracts/src/api/share.ts +++ b/packages/contracts/src/api/share.ts @@ -443,6 +443,8 @@ export const SHARE_COMMENT_ERROR_CODES = [ 'RATE_LIMITED', /** 413 — request body exceeded the transport limit. */ 'PAYLOAD_TOO_LARGE', + /** 409 — a new write targets a superseded share version; keep draft and reload. */ + 'STALE_PUBLICATION', ] as const; export type ShareCommentErrorCode = (typeof SHARE_COMMENT_ERROR_CODES)[number]; @@ -526,6 +528,10 @@ export interface ShareComment { elementId: string; selector: string; htmlHint: string; + /** Server-stamped alias version at insertion, not a viewer-supplied value. */ + anchoredVersion?: number; + /** Optional normalized anchor geometry; never grants write authority. */ + position?: { left: number; top: number; width: number; height: number; viewportWidth: number; viewportHeight: number }; status: string; createdAt: number; updatedAt: number; @@ -542,6 +548,8 @@ export interface ShareCommentListResponse { /** `POST` a comment from the share page. */ export interface ShareCommentCreateRequest { + /** Pinned version of the rendered snapshot; a new key is rejected if the alias advanced. */ + expectedVersion: number; /** See {@link IDEMPOTENCY_KEY_MAX_LENGTH}. Required — replay safety. */ idempotencyKey: string; note: string; @@ -549,6 +557,7 @@ export interface ShareCommentCreateRequest { elementId: string; selector: string; htmlHint: string; + position?: { left: number; top: number; width: number; height: number; viewportWidth: number; viewportHeight: number }; } /** Same shape as one list item — see {@link ShareComment}. */ @@ -1155,7 +1164,16 @@ export interface ShareBridgeLocatePayload { } /** host → frame. The full pin set to draw; replaces whatever is drawn. */ export interface ShareBridgePinsPayload { - items: ReadonlyArray<{ id: string; elementId: string; selector: string; display?: ShareBridgePinDisplay }>; + items: ReadonlyArray<{ + id: string; + elementId: string; + selector: string; + display?: ShareBridgePinDisplay; + /** Old-publication comments never bind to same-named targets in a newer document. */ + forceGhost?: boolean; + /** Stored comment geometry, displayed only as untrusted layout fallback. */ + ghostPosition?: ShareBridgeViewportRect; + }>; } /** frame → host. The frame has loaded and will accept the other three types. */ @@ -1662,6 +1680,14 @@ export interface SharePublishLinkUnavailable { code: 'PUBLIC_SHARE_WEB_URL_UNAVAILABLE'; } +/** POST /api/projects/:id/files/:path/publish-public. + * Omitted mode always means a fresh publish/update; only a previously stopped + * authoritative alias may be reopened without transferring file bytes. + */ +export interface SharePublishRequest { + mode?: 'resume'; +} + /** Confirmed cancellation from DELETE /api/projects/:id/files/:path/publish-public. * The stable alias is stopped, not erased, and can be resumed by the owner. */ export interface ShareUnpublishResponse { @@ -2137,6 +2163,13 @@ export interface CommentBackfillState { publicationRevision: string; /** Whether anything will retry on its own. Meaningful when `state` is `failed`. */ retryable: boolean; + /** + * True only when this exact publication was committed by the remotely verified + * stopped-link resume path. False for a witnessed first publish/update; omitted + * for legacy batches whose origin was never recorded. Never infer this from + * a stable URL, a local switch or a previous screenshot. + */ + reopened?: boolean; /** Machine-readable cause, when the producer recorded one. */ code?: string; } diff --git a/ui-audit/goal66/owner-s9r-re-review.md b/ui-audit/goal66/owner-s9r-re-review.md new file mode 100644 index 0000000000..cc096e1bb3 --- /dev/null +++ b/ui-audit/goal66/owner-s9r-re-review.md @@ -0,0 +1,7 @@ +# S9-R owner re-review — BLOCK + +- **PASS** compensation: `apps/daemon/src/routes/collab-sync.ts:1338-1358` catches failed local completion after Vela resume, attempts `stopVelaShare`, returns bounded 502 on compensated stop and a manual-revoke error when stop fails. HTTP S9-R spec toggles `failComplete`/`failStop`, observes stopped/active remote status, then successfully invokes owner DELETE with no local row (`apps/daemon/tests/public-share-origin-http.test.ts:153-235`). +- **PASS** exact file/slug checks and no-upload version preservation: DELETE remote-only requires matching active source file and slug (`collab-sync.ts:1564-1568`); S9-R wrong-file request returns 404. Resume calls only `share resume-existing`, maps its original receipt without rebuilding/uploading bytes (`collab-sync.ts:1334-1343`); spec checks receipt equality, one upload, and command sequence. Wrong slug is source-enforced, but not exercised by S9-R HTTP spec. +- **BLOCK — owner evidence absent on remote-only DELETE**, `apps/daemon/src/routes/collab-sync.ts:1551-1568`: the owner check rejects only a *non-null* catalog owner different from the caller. If `resolveSharedProject` returns null, any verified workspace member with `canShareProjects` and visibility of an active remote project/file/slug proceeds to `share stop` despite having no local publication revision or owner witness. `readProjectShareState` checks active workspace membership, not project ownership (`apps/daemon/src/collab/vela-project-share-state.ts:25-28,42-46`). Fail closed on null catalog for remote-only recovery unless a separate authoritative owner witness proves the caller owns the project; add HTTP wrong-owner and wrong-slug cases with no local revision and assert no stop command. + +Validation: `cd apps/daemon && pnpm exec vitest run -c vitest.config.ts tests/public-share-origin-http.test.ts tests/collab-sync-routes.test.ts` — 2 files / 142 tests passed. No browser, candidate source, or tests edited. Residual risk: CLI/Vela backend may independently deny a non-owner, but the daemon route does not establish the claimed owner-only authorization; S9-R spec does not exercise the null-catalog wrong-owner case.