From 340a0a1e59ee85f6cd5f09b7f804c40d7428d419 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 9 Oct 2026 09:10:34 -0400 Subject: [PATCH 1/2] Fix group and public workspace overview counts Load scope-bound resource totals, preserve partial results, and show accessible count failure indicators. Add regression coverage and documentation; bump app to 0.261.310. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../workspace/SharedWorkspaceOverview.tsx | 51 ++++ .../workspace/WorkspaceOverview.tsx | 12 +- application/v2_ui/src/lib/agentDelegation.ts | 12 + .../v2_ui/src/lib/identityWorkbench.ts | 2 +- application/v2_ui/src/lib/promptWorkbench.ts | 20 ++ .../v2_ui/src/lib/sharedWorkspaceCounts.ts | 89 ++++++ application/v2_ui/src/lib/workflowEditor.ts | 10 + .../v2_ui/src/pages/GroupWorkspacePage.tsx | 4 +- .../v2_ui/src/pages/PublicWorkspacePage.tsx | 4 +- .../SHARED_WORKSPACE_OVERVIEW_COUNTS_FIX.md | 89 ++++++ docs/guides/manage-group-workspaces.md | 15 + docs/guides/use-public-workspaces.md | 13 + .../test_v2_shared_workspace_counts.mjs | 155 ++++++++++ ui_tests/fixtures/group_workspace.py | 19 +- ui_tests/fixtures/public_members.py | 11 +- ui_tests/fixtures/public_workspace.py | 35 ++- ...est_v2_shared_workspace_overview_counts.py | 270 ++++++++++++++++++ 18 files changed, 796 insertions(+), 17 deletions(-) create mode 100644 application/v2_ui/src/components/workspace/SharedWorkspaceOverview.tsx create mode 100644 application/v2_ui/src/lib/sharedWorkspaceCounts.ts create mode 100644 docs/explanation/fixes/SHARED_WORKSPACE_OVERVIEW_COUNTS_FIX.md create mode 100644 functional_tests/test_v2_shared_workspace_counts.mjs create mode 100644 ui_tests/test_v2_shared_workspace_overview_counts.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 55eb38109..39190f940 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.309" +VERSION = "0.261.310" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/v2_ui/src/components/workspace/SharedWorkspaceOverview.tsx b/application/v2_ui/src/components/workspace/SharedWorkspaceOverview.tsx new file mode 100644 index 000000000..b92ca4916 --- /dev/null +++ b/application/v2_ui/src/components/workspace/SharedWorkspaceOverview.tsx @@ -0,0 +1,51 @@ +// SharedWorkspaceOverview.tsx + +import { useEffect, useMemo, useState } from 'react'; +import { WorkspaceOverview, type WorkspaceOverviewSection } from './WorkspaceOverview'; +import { + createSharedWorkspaceCountLoaders, loadWorkspaceCounts, + type SharedWorkspaceContext, type WorkspaceCountResult, +} from '../../lib/sharedWorkspaceCounts'; +import type { ResolvedWorkspaceSection } from '../../lib/workspaceSections'; +import type { WorkspaceSectionGroup } from '../../lib/types'; + +export function SharedWorkspaceOverview({ + context, resolved, basePath, description, groupBlurbs, suspended = false, +}: { + context: SharedWorkspaceContext; + resolved: ResolvedWorkspaceSection[]; + basePath: string; + description: string; + groupBlurbs?: Partial>; + suspended?: boolean; +}) { + const loaders = useMemo(() => createSharedWorkspaceCountLoaders(context), [context]); + const sectionKey = resolved.filter((entry) => entry.enabled).map((entry) => entry.section.id).join(','); + const identity = useMemo(() => ({ loaders, sectionKey, suspended }), [loaders, sectionKey, suspended]); + const [state, setState] = useState<{ + identity: typeof identity; + results: Record; + }>({ identity, results: {} }); + useEffect(() => { + const controller = new AbortController(); + setState({ identity, results: {} }); + if (!suspended) { + void loadWorkspaceCounts(loaders, sectionKey.split(','), controller.signal, (id, result) => { + setState((current) => ({ + identity, + results: { ...(current.identity === identity ? current.results : {}), [id]: result }, + })); + }); + } + return () => controller.abort(); + }, [identity, loaders, sectionKey, suspended]); + const results = state.identity === identity ? state.results : {}; + const counts: Record = {}; + const failedCounts = new Set(); + Object.entries(results).forEach(([id, result]) => { + if (result.failed) failedCounts.add(id); + else counts[id] = result.count; + }); + return ; +} diff --git a/application/v2_ui/src/components/workspace/WorkspaceOverview.tsx b/application/v2_ui/src/components/workspace/WorkspaceOverview.tsx index 5e5710de8..ee5cc0783 100644 --- a/application/v2_ui/src/components/workspace/WorkspaceOverview.tsx +++ b/application/v2_ui/src/components/workspace/WorkspaceOverview.tsx @@ -1,7 +1,7 @@ // WorkspaceOverview.tsx import { Link } from 'react-router-dom'; -import { ArrowRight, Lock } from 'lucide-react'; +import { ArrowRight, CircleX, Lock } from 'lucide-react'; import { GlassPanel } from '../ui/primitives'; import { SectionIntro } from './primitives'; import type { WorkspaceNavigationSection } from './WorkspaceShell'; @@ -14,12 +14,13 @@ export interface WorkspaceOverviewSection extends WorkspaceNavigationSection { } export function WorkspaceOverview({ - resolved, basePath, description, counts = {}, showRelationships = true, groupBlurbs = {}, + resolved, basePath, description, counts = {}, failedCounts, showRelationships = true, groupBlurbs = {}, }: { resolved: ResolvedWorkspaceSection[]; basePath: string; description: string; counts?: Record; + failedCounts?: ReadonlySet; showRelationships?: boolean; /** A scope's own wording for a group's blurb; a group not named here keeps the shared one. */ groupBlurbs?: Partial>; @@ -60,7 +61,12 @@ export function WorkspaceOverview({

{section.label} - {typeof count === 'number' ? {count} : null} + {typeof count === 'number' ? {count} : null} + {failedCounts?.has(section.id) ? ( + + + ) : null} {section.availabilityLabel ? {section.availabilityLabel} : null}

{section.blurb}

diff --git a/application/v2_ui/src/lib/agentDelegation.ts b/application/v2_ui/src/lib/agentDelegation.ts index 18c1339b2..11ee6d41b 100644 --- a/application/v2_ui/src/lib/agentDelegation.ts +++ b/application/v2_ui/src/lib/agentDelegation.ts @@ -113,6 +113,18 @@ export async function fetchDelegationActions(scope: DelegationScope, signal?: Ab return actions.filter((action) => action.type === 'agent'); } +export async function fetchDelegationActionCount(scope: DelegationScope, signal?: AbortSignal): Promise { + const response = await api.get(delegationResourceUrl(scope, 'plugins'), signal); + const actions = Array.isArray(response) ? response + : response && typeof response === 'object' && 'actions' in response ? response.actions : undefined; + if (!Array.isArray(actions) || !actions.every((action) => + action && typeof action === 'object' && typeof action.id === 'string' + && action.id && typeof action.type === 'string')) { + throw new Error('The workspace returned an invalid Call agent action list.'); + } + return actions.filter((action) => action.type === 'agent').length; +} + export async function fetchDelegationAgents(scope: DelegationScope, signal?: AbortSignal) { const response = await api.get( delegationResourceUrl(scope, 'agents'), signal, diff --git a/application/v2_ui/src/lib/identityWorkbench.ts b/application/v2_ui/src/lib/identityWorkbench.ts index 95a3fbbb3..ef80981a4 100644 --- a/application/v2_ui/src/lib/identityWorkbench.ts +++ b/application/v2_ui/src/lib/identityWorkbench.ts @@ -180,7 +180,7 @@ function identitiesFromResponse(value: unknown): WorkspaceIdentity[] { if (isRecord(value) && Array.isArray(value.identities)) { return value.identities as WorkspaceIdentity[]; } - return []; + throw new Error('The workspace returned an invalid identity list. Refresh and try again.'); } /** The read, create and update routes wrap the record under `identity`. */ diff --git a/application/v2_ui/src/lib/promptWorkbench.ts b/application/v2_ui/src/lib/promptWorkbench.ts index fcb714fcf..1af07dbba 100644 --- a/application/v2_ui/src/lib/promptWorkbench.ts +++ b/application/v2_ui/src/lib/promptWorkbench.ts @@ -250,6 +250,26 @@ function publicPromptsUrl(workspaceId: string, promptId?: string, params?: URLSe return query ? `${path}?${query}` : path; } +export async function fetchSharedPromptCount( + scope: Exclude, signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ page: '1', page_size: '1' }); + const url = scope.kind === 'group' + ? groupPromptsUrl(scope.id, undefined, params) + : publicPromptsUrl(scope.id, undefined, params); + const response = await api.get(url, signal); + if (!isRecord(response) || !Array.isArray(response.prompts) + || typeof response.total_count !== 'number' || !Number.isSafeInteger(response.total_count) + || response.total_count < response.prompts.length) { + throw new Error('The workspace returned an invalid prompt count.'); + } + response.prompts.forEach((prompt) => { + if (scope.kind === 'group') assertGroupPromptScope(prompt, scope.id); + else assertPublicPromptScope(prompt, scope.id); + }); + return response.total_count; +} + /** Prove a returned prompt belongs to the requested public workspace, as the public reader does. */ function assertPublicPromptScope(prompt: WorkspacePrompt, workspaceId: string, id?: string): void { if (!prompt || typeof prompt.id !== 'string' || !prompt.id diff --git a/application/v2_ui/src/lib/sharedWorkspaceCounts.ts b/application/v2_ui/src/lib/sharedWorkspaceCounts.ts new file mode 100644 index 000000000..28e7becf9 --- /dev/null +++ b/application/v2_ui/src/lib/sharedWorkspaceCounts.ts @@ -0,0 +1,89 @@ +// sharedWorkspaceCounts.ts + +import { createGroupDocumentReader, createPublicDocumentReader, type DocumentReadAdapter } from './documentReadAdapter'; +import { createGroupActionWorkbench } from './actionWorkbench'; +import { createGroupAgentWorkbench } from './agentWorkbench'; +import { fetchDelegationActionCount } from './agentDelegation'; +import { createGroupFileSourceWorkbench, createPublicFileSourceWorkbench } from './fileSourceWorkbench'; +import { createGroupIdentityWorkbench, createPublicIdentityWorkbench } from './identityWorkbench'; +import { createGroupMembershipClient, createPublicMembershipClient } from './groupMembership'; +import { createGroupModelConnectionsAdapter } from './modelConnections'; +import { fetchSharedPromptCount } from './promptWorkbench'; +import { fetchScopedWorkflowCount } from './workflowEditor'; +import type { GroupWorkspaceContext, PublicWorkspaceContext } from './workspaceContext'; + +export type SharedWorkspaceContext = GroupWorkspaceContext | PublicWorkspaceContext; +export type WorkspaceCountLoader = (signal: AbortSignal) => Promise; +export type WorkspaceCountLoaders = Partial>; +export type WorkspaceCountResult = { count: number; failed: false } | { failed: true }; + +export function validWorkspaceCount(value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error('The workspace returned an invalid count.'); + } + return value; +} + +function documentCountLoaders(reader: DocumentReadAdapter): WorkspaceCountLoaders { + return { + documents: async (signal) => reader.queries.facets + ? validWorkspaceCount((await reader.facets(signal)).total) + : validWorkspaceCount((await reader.list({ page: 1, pageSize: 1 }, signal)).total_count), + tags: async (signal) => (await reader.tags(signal)).tags?.length ?? 0, + }; +} + +function isGroupContext(context: SharedWorkspaceContext): context is GroupWorkspaceContext { + return context.scope.kind === 'group'; +} + +export function createSharedWorkspaceCountLoaders(context: SharedWorkspaceContext): WorkspaceCountLoaders { + const { id } = context.scope; + const name = context.workspace.name; + const membersQuery = { page: 1, pageSize: 1, search: '', role: null }; + if (!isGroupContext(context)) { + const scope = { kind: 'public' as const, id, name }; + return { + ...documentCountLoaders(createPublicDocumentReader(id, name, context.document_queries)), + prompts: (signal) => fetchSharedPromptCount(scope, signal), + sync: async (signal) => (await createPublicFileSourceWorkbench(scope, context.file_source_management).list(signal)).length, + identities: async (signal) => (await createPublicIdentityWorkbench(scope, context.identity_management).list(signal)).length, + members: context.role === 'User' ? undefined + : async (signal) => (await createPublicMembershipClient(id).list(membersQuery, signal)).totalCount, + }; + } + const scope = { kind: 'group' as const, id, name }; + return { + ...documentCountLoaders(createGroupDocumentReader(id, name, context.document_queries)), + prompts: (signal) => fetchSharedPromptCount(scope, signal), + sync: async (signal) => (await createGroupFileSourceWorkbench(scope, context.file_source_management).list(signal)).length, + identities: async (signal) => (await createGroupIdentityWorkbench(scope, context.identity_management).list(signal)).length, + agents: async (signal) => (await createGroupAgentWorkbench(scope, context.agent_management, null).listAgents(signal)).length, + actions: context.sections.actions.enabled + ? async (signal) => (await createGroupActionWorkbench(scope, context.action_management).listActions(signal)).length + : (signal) => fetchDelegationActionCount({ type: 'group', groupId: id }, signal), + workflows: (signal) => fetchScopedWorkflowCount({ type: 'group', groupId: id }, signal), + endpoints: async (signal) => (await createGroupModelConnectionsAdapter(scope, context.endpoint_management).list(signal)).endpoints.length, + members: async (signal) => (await createGroupMembershipClient(id).list(membersQuery, signal)).totalCount, + }; +} + +/** Independent results are published as they arrive; one failed service never hides the others. */ +export async function loadWorkspaceCounts( + loaders: WorkspaceCountLoaders, + sectionIds: readonly string[], + signal: AbortSignal, + onResult: (id: string, result: WorkspaceCountResult) => void, +): Promise { + await Promise.all(sectionIds.map(async (id) => { + const load = loaders[id]; + if (!load || signal.aborted) return; + let result: WorkspaceCountResult; + try { + result = { count: validWorkspaceCount(await load(signal)), failed: false }; + } catch { + result = { failed: true }; + } + if (!signal.aborted) onResult(id, result); + })); +} diff --git a/application/v2_ui/src/lib/workflowEditor.ts b/application/v2_ui/src/lib/workflowEditor.ts index 662cd07e8..2b5a99065 100644 --- a/application/v2_ui/src/lib/workflowEditor.ts +++ b/application/v2_ui/src/lib/workflowEditor.ts @@ -2534,6 +2534,16 @@ export async function fetchScopedWorkflows( .map((workflow) => normalizeWorkflowDefinition(workflow, scope)); } +export async function fetchScopedWorkflowCount(scope: WorkflowScope, signal?: AbortSignal): Promise { + const response = await api.get(workflowUrl(scope), signal); + const workflows = Array.isArray(response) ? response : isRecord(response) ? response.workflows : undefined; + if (!Array.isArray(workflows) || !workflows.every((workflow) => + isRecord(workflow) && typeof workflow.id === 'string' && workflow.id)) { + throw new Error('The workspace returned an invalid workflow list.'); + } + return workflows.length; +} + export function saveWorkflowDefinition( scope: WorkflowScope, draft: WorkflowDefinition, diff --git a/application/v2_ui/src/pages/GroupWorkspacePage.tsx b/application/v2_ui/src/pages/GroupWorkspacePage.tsx index 435731c96..1efb73f9f 100644 --- a/application/v2_ui/src/pages/GroupWorkspacePage.tsx +++ b/application/v2_ui/src/pages/GroupWorkspacePage.tsx @@ -8,7 +8,7 @@ import { PageHeader } from '../components/layout/PageHeader'; import { EmptyState, GlassButton, Skeleton } from '../components/ui/primitives'; import { GroupWorkspacePicker } from '../components/workspace/GroupWorkspacePicker'; import { WorkspaceLeavePrompt } from '../components/workspace/WorkspaceEditorFrame'; -import { WorkspaceOverview } from '../components/workspace/WorkspaceOverview'; +import { SharedWorkspaceOverview } from '../components/workspace/SharedWorkspaceOverview'; import { WorkspaceShell } from '../components/workspace/WorkspaceShell'; import { Pill, SectionIntro } from '../components/workspace/primitives'; import { @@ -452,7 +452,7 @@ export function GroupWorkspacePage() {
About this group
{context.workspace.description || 'No description provided.'}
Owner
{context.workspace.owner.display_name || 'Owner information unavailable'}{context.workspace.owner.email ? ` · ${context.workspace.owner.email}` : ''}
- ) : !selected ? } title="Section not found" description="Choose a section from this workspace's navigation." /> diff --git a/application/v2_ui/src/pages/PublicWorkspacePage.tsx b/application/v2_ui/src/pages/PublicWorkspacePage.tsx index 73f5315ec..f6ec4dd57 100644 --- a/application/v2_ui/src/pages/PublicWorkspacePage.tsx +++ b/application/v2_ui/src/pages/PublicWorkspacePage.tsx @@ -15,7 +15,7 @@ import { PageHeader } from '../components/layout/PageHeader'; import { EmptyState, GlassButton, GlassPanel, Skeleton } from '../components/ui/primitives'; import { PublicWorkspacePicker } from '../components/workspace/PublicWorkspacePicker'; import { WorkspaceLeavePrompt } from '../components/workspace/WorkspaceEditorFrame'; -import { WorkspaceOverview } from '../components/workspace/WorkspaceOverview'; +import { SharedWorkspaceOverview } from '../components/workspace/SharedWorkspaceOverview'; import { WorkspaceShell } from '../components/workspace/WorkspaceShell'; import { Pill, SectionIntro } from '../components/workspace/primitives'; import { @@ -307,7 +307,7 @@ export function PublicWorkspacePage() {
About this workspace
{context.workspace.description || 'No description provided.'}
Owner
{context.workspace.owner.display_name || 'Owner information unavailable'}{context.workspace.owner.email ? ` · ${context.workspace.owner.email}` : ''}
- diff --git a/docs/explanation/fixes/SHARED_WORKSPACE_OVERVIEW_COUNTS_FIX.md b/docs/explanation/fixes/SHARED_WORKSPACE_OVERVIEW_COUNTS_FIX.md new file mode 100644 index 000000000..96c47c949 --- /dev/null +++ b/docs/explanation/fixes/SHARED_WORKSPACE_OVERVIEW_COUNTS_FIX.md @@ -0,0 +1,89 @@ +# Shared Workspace Overview Counts Fix + +Fixed in version: **0.261.310**, recorded in +`application/single_app/config.py`. + +## Issue and root cause + +My workspace displayed resource counts, but the group and public workspace +overviews did not. Both shared pages rendered `WorkspaceOverview` without its +`counts` property. The badge renderer was already working; no shared-scope +count loader supplied it with data. + +## Technical details + +`SharedWorkspaceOverview.tsx` now loads counts only while an authorized shared +workspace overview is mounted. `sharedWorkspaceCounts.ts` reuses the scoped +document readers, authoring adapters, workflow and delegation readers, and +membership clients. Every request explicitly names the selected workspace. +Disabled sections are not fetched, and personal/admin resource readers are +never used as a fallback. + +Documents use validated facets, or a minimal document page's total when facets +are unavailable. Prompts and Members use server totals from minimal unfiltered +pages, avoiding a first-page limit. Tags count distinct vocabulary entries. +Unpaginated resource collections use their validated list lengths. +Group Actions counts the full native list when enabled, or only Call agent +tools when that is the available surface. + +Independent results render as they arrive. Zero is a successful empty count. +Malformed responses and failed reads produce a small red circled X with an +accessible label and tooltip, never a false zero. Locked cards remain uncounted. +Settings, Activity, and Statistics are navigation destinations, not resource +collections, so no count is added to them. + +The loader aborts when the overview unmounts or its authorized context changes. +An identity-bound state prevents an old viewer/workspace's results from rendering +during a switch, even before effect cleanup. Returning from another section or +revalidating workspace access reloads counts. Personal overview behavior is +unchanged. + +Members counts current roster entries, excluding pending requests. In a public +workspace, these are its Owner, Admins, and DocumentManagers, not all readers. +The existing membership API's access and disclosure rules still apply. +Public readers outside that roster do not request or display a Members count. + +## Files modified + +- Shared overview component and badge renderer under `application/v2_ui/src/components/workspace/`. +- Scoped count readers and related prompt, identity, workflow, and delegation helpers under `application/v2_ui/src/lib/`. +- `GroupWorkspacePage.tsx` and `PublicWorkspacePage.tsx`. +- `application/single_app/config.py` for the application patch version. +- Workspace UI fixtures and the regression tests listed below. +- Group and public workspace guides. + +## Testing and validation + +`functional_tests/test_v2_shared_workspace_counts.mjs` executes the production +readers with controlled HTTP responses. It covers full totals beyond 500 prompts, +zero counts, malformed responses, explicit group/public scope, document totals +without facets, Call agent-only counts, partial failure, and aborted late reads. + +`ui_tests/test_v2_shared_workspace_overview_counts.py` exercises the real built SPA +through the existing Azure Playwright-compatible fixtures. It covers desktop +and mobile in both themes, available/locked cards, pagination totals, failures, +workspace switching, stale responses, returning to Overview, and access +revalidation. The fixture boundary rejects unexpected personal/admin reads. + +Before the fix, shared overview cards had no counts. After the fix, each available +resource card reports its selected workspace's total without making a failed +service look like an empty workspace. There are no new routes, settings, +dependencies, storage migrations, or classic-UI changes. + +### Validation results + +- The production-reader count test passed, as did the existing personal section + and group/public context runtime contracts. +- The production V2 TypeScript/Vite build passed. +- All **18** new count UI cases passed in local Chromium through the shared + Azure-compatible connection fixture. The combined count, group-shell, and + public-journey run passed **79 of 80** cases. +- The remaining public journey checks immediate deployment-wide feature + activation on focus. It still fails against the existing bootstrap refresh + cooldown; this count fix does not change that behavior. +- The personal workspace and group prompt seam Python contracts passed + (**15** checks). Additional legacy Flask-backed checks could not complete + because the installed Flask test client expects `werkzeug.__version__`, + which the installed Werkzeug no longer exposes. +- Documentation coverage/quality checks and the whitespace check passed. + The changed-UI detector reported no deterministic findings. diff --git a/docs/guides/manage-group-workspaces.md b/docs/guides/manage-group-workspaces.md index ae2ade419..c5a59adaa 100644 --- a/docs/guides/manage-group-workspaces.md +++ b/docs/guides/manage-group-workspaces.md @@ -42,6 +42,21 @@ description, like the group directory. A selected group stays selected when searching or changing result pages. Section URLs include the group ID, so a bookmark opens the intended group. +### Read overview counts + +From version **0.261.310**, the overview shows a count beside each available +resource card, including **Members**, like My workspace. Counts belong to the +selected group and cover the complete visible collection, not just the first +page. Tags count distinct labels; Members counts current members, not pending +join requests. When Actions offers only Call agent tools, its count covers those +tools rather than the full action collection. + +A zero means the collection is empty. Counts stay blank while loading; a small +red circled X means that count could not be loaded, without hiding the other +counts or blocking navigation. Returning to Overview or refreshing workspace +access reloads the counts. Locked cards and Settings, Activity, and Statistics +do not show resource counts. + ### Find, join, or create a group From version **0.261.150**, choose **Browse all groups** in the header, or diff --git a/docs/guides/use-public-workspaces.md b/docs/guides/use-public-workspaces.md index e6e6c8011..74aaeed12 100644 --- a/docs/guides/use-public-workspaces.md +++ b/docs/guides/use-public-workspaces.md @@ -46,6 +46,19 @@ same one. Choosing a workspace also makes it your active public workspace for chat, but the page itself never depends on that selection: every request names the workspace it is for. +From version **0.261.310**, the overview shows counts for available resource +cards, including Documents, Prompts, File sources, Identities, and Members. +Counts cover the complete collection visible to you in the selected workspace, +not the first page of results. Members counts the workspace's Owner, Admins, +and DocumentManagers, not everyone who can read its published content or pending +requests. +Readers who are not on that roster do not get a Members count. + +A zero means the collection is empty. Counts remain blank while loading; a small +red circled X means that particular count could not load. Other counts and +navigation keep working. Returning to Overview or refreshing workspace access +reloads them. Locked cards and Settings, Activity, and Statistics stay uncounted. + - **Browse.** Search, filter, sort, and page through the workspace's documents, and inspect a document's details and version history. Every member can do this. diff --git a/functional_tests/test_v2_shared_workspace_counts.mjs b/functional_tests/test_v2_shared_workspace_counts.mjs new file mode 100644 index 000000000..2df05f419 --- /dev/null +++ b/functional_tests/test_v2_shared_workspace_counts.mjs @@ -0,0 +1,155 @@ +// test_v2_shared_workspace_counts.mjs +// Version: 0.261.310 +// Implemented in: 0.261.310 +// Executes the real scoped readers, pagination totals, partial failures and cancellation. + +import assert from 'node:assert/strict'; +import './test_support/tsResolve.mjs'; + +const { createSharedWorkspaceCountLoaders, loadWorkspaceCounts, validWorkspaceCount } = + await import('../application/v2_ui/src/lib/sharedWorkspaceCounts.ts'); + +const originalFetch = globalThis.fetch; +let calls = []; +let responder; +globalThis.fetch = async (url) => { + const parsed = new URL(url, 'http://simplechat.test'); + calls.push(parsed); + const payload = await responder(parsed); + return new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } }); +}; + +function context(kind, id = 'workspace-a', facets = true) { + return { + scope: { kind, id }, viewer_id: 'viewer', role: 'Owner', workspace: { name: id }, + sections: { actions: { enabled: true } }, + document_queries: { facets, places: true, sort_fields: ['_ts'] }, + }; +} + +function payload(url) { + const { pathname: path } = url; + const publicScope = path.includes('/public-workspaces/'); + const id = path.startsWith('/api/group/') || path.startsWith('/api/group_documents') + ? url.searchParams.get('group_id') : path.split('/')[3]; + const row = { id: 'item', group_id: id, public_workspace_id: id }; + if (path.endsWith('/facets')) return { + total: 1700, untagged: 0, processing: 0, errors: 0, recent: 0, + shared_with_me: 0, by_tag: {}, by_classification: {}, + }; + if (path.endsWith('/tags')) return { tags: [{ name: 'team', count: 1700 }] }; + if (path === '/api/group_documents' || path.endsWith('/documents')) return { + documents: [], total_count: 1700, page: 1, page_size: 1, + }; + if (path.endsWith('/prompts')) return { + prompts: [{ ...row, public_id: id }], total_count: 1001, page: 1, page_size: 1, + }; + if (path.endsWith('/membership/members')) return { + members: [], total_count: 1201, page: 1, page_size: 1, + membership_management: { schema_version: 1, operations: [] }, + }; + if (path.endsWith('/agents')) return { agents: [row, { ...row, id: 'second' }] }; + if (path.endsWith('/actions')) return { actions: [row, { ...row, id: 'second' }, { ...row, id: 'third' }] }; + if (path.endsWith('/plugins')) return { actions: [ + { ...row, type: 'agent' }, { ...row, id: 'second', type: 'agent' }, + { ...row, id: 'third', type: 'openapi' }, + ] }; + if (path.endsWith('/identities')) return { identities: [row] }; + if (path.endsWith('/file-sources')) return { + file_sources: [{ ...row, config_revision: 'revision', source_actions: [] }], + }; + if (path.endsWith('/workflows')) return { workflows: [row] }; + if (path.endsWith('/model-endpoints')) return { + endpoints: [{ ...row, revision: 'revision', endpoint_actions: [] }], + }; + throw new Error(`Unexpected ${publicScope ? 'public' : 'group'} read: ${url}`); +} + +async function readCounts(ctx, ids) { + calls = []; + const results = {}; + await loadWorkspaceCounts(createSharedWorkspaceCountLoaders(ctx), ids, + new AbortController().signal, (id, result) => { results[id] = result; }); + return results; +} + +try { + responder = payload; + const groupIds = ['documents', 'tags', 'prompts', 'sync', 'agents', 'actions', 'workflows', 'identities', 'endpoints', 'members']; + const group = await readCounts(context('group'), groupIds); + for (const id of groupIds) assert.equal(group[id].failed, false, `${id} must load`); + assert.equal(group.documents.count, 1700); + assert.equal(group.prompts.count, 1001); + assert.equal(group.members.count, 1201); + assert.equal(group.agents.count, 2); + assert.equal(group.actions.count, 3); + for (const url of calls) { + assert.ok(!url.pathname.includes('/api/user/') && !url.pathname.includes('/api/v2/admin/')); + assert.ok(url.pathname.includes('/workspace-a/') || url.searchParams.get('group_id') === 'workspace-a'); + if (url.pathname.endsWith('/prompts') || url.pathname.endsWith('/members')) { + assert.equal(url.searchParams.get('page_size'), '1'); + } + } + + const publicIds = ['documents', 'tags', 'prompts', 'sync', 'identities', 'members']; + const publicCounts = await readCounts(context('public'), [...publicIds, 'agents', 'settings', 'statistics']); + assert.deepEqual(Object.keys(publicCounts).sort(), publicIds.sort()); + for (const result of Object.values(publicCounts)) assert.equal(result.failed, false); + for (const url of calls) assert.ok(url.pathname.startsWith('/api/public-workspaces/workspace-a/')); + const publicReader = { ...context('public'), role: 'User' }; + const readerCounts = await readCounts(publicReader, ['documents', 'members']); + assert.deepEqual(Object.keys(readerCounts), ['documents']); + assert.ok(!calls.some((url) => url.pathname.endsWith('/members'))); + + const fallback = await readCounts(context('group', 'workspace-a', false), ['documents']); + assert.equal(fallback.documents.count, 1700); + assert.equal(calls.length, 1); + assert.equal(calls[0].searchParams.get('page_size'), '1'); + const delegationContext = context('group'); + delegationContext.sections.actions.enabled = false; + const delegation = await readCounts(delegationContext, ['actions']); + assert.equal(delegation.actions.count, 2); + assert.equal(calls[0].pathname, '/api/group/plugins'); + + for (const bad of [null, {}, { prompts: [], total_count: -1 }, { prompts: [], total_count: '42' }, + { prompts: [], total_count: 0.5 }, { prompts: [{ id: 'wrong', group_id: 'other' }], total_count: 1 }]) { + responder = (url) => url.pathname.endsWith('/prompts') ? bad : payload(url); + const partial = await readCounts(context('group'), ['prompts', 'tags']); + assert.deepEqual(partial.prompts, { failed: true }); + assert.equal(partial.tags.count, 1); + } + for (const id of ['identities', 'workflows', 'actions', 'agents', 'sync', 'endpoints', 'members']) { + responder = () => ({}); + const malformed = await readCounts(context('group'), [id]); + assert.deepEqual(malformed[id], { failed: true }, `${id} cannot turn a malformed response into zero`); + } + responder = (url) => url.pathname.endsWith('/prompts') + ? { prompts: [], total_count: 0 } : payload(url); + const empty = await readCounts(context('group'), ['prompts']); + assert.deepEqual(empty.prompts, { count: 0, failed: false }); + for (const bad of [undefined, null, -1, 1.5, '0', NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => validWorkspaceCount(bad)); + } + + let finish; + const gate = new Promise((resolve) => { finish = resolve; }); + const controller = new AbortController(); + const seen = []; + const pending = loadWorkspaceCounts({ + documents: async () => gate, + tags: async () => 0, + }, ['documents', 'tags', 'locked'], controller.signal, (id, result) => seen.push([id, result])); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(seen, [['tags', { count: 0, failed: false }]]); + controller.abort(); + finish(99); + await pending; + assert.equal(seen.length, 1, 'An obsolete response must never publish a count'); + let invoked = false; + await loadWorkspaceCounts({ documents: async () => { invoked = true; return 1; } }, + ['documents'], controller.signal, () => assert.fail('Aborted loads cannot publish')); + assert.equal(invoked, false); + console.log('Shared workspace count contracts passed.'); +} finally { + globalThis.fetch = originalFetch; +} diff --git a/ui_tests/fixtures/group_workspace.py b/ui_tests/fixtures/group_workspace.py index b56bab5cc..da09283b1 100644 --- a/ui_tests/fixtures/group_workspace.py +++ b/ui_tests/fixtures/group_workspace.py @@ -1,7 +1,7 @@ # group_workspace.py """ Closed HTTP fixtures for the real V2 group workspace shell. -Version: 0.261.305 +Version: 0.261.310 Implemented in: 0.261.127 Members section in the group context (M7B): 0.261.155 File source credential identifiers modelled as `_prepare_auth_payload` stores them: 0.261.156 @@ -1489,6 +1489,21 @@ def _dispatch(self, route, entry): self.unexpected_requests.append(f"{method} {path} (admin route from a group page)") self._json(route, {"error": "Admin routes are not available on group pages."}, 500) return + if method == "GET" and entry.query.get("page_size") == ["1"]: + match = re.fullmatch(r"/api/groups/([^/]+)/(prompts|membership/members)", path) + if match: + group_id, resource = match.groups() + assert group_id in self.groups + payload = {"page": 1, "page_size": 1, "total_count": 0} + if resource == "prompts": + payload["prompts"] = [] + else: + payload.update({ + "members": [], + "membership_management": {"schema_version": 1, "operations": []}, + }) + self._json(route, payload) + return if path.startswith("/api/groups/") and path.endswith("/agent-options"): self._agent_options(route, entry) return @@ -1666,7 +1681,7 @@ def _dispatch(self, route, entry): elif not self._group_workflow_member_allowed(group_id): self._json(route, {"error": "Forbidden."}, 403) else: - self._json(route, {"workflows": copy.deepcopy(self.workflows[group_id])}) + self._json(route, {"workflows": copy.deepcopy(self.workflows.get(group_id, []))}) elif path == "/api/group/workflows" and method == "POST": if not self._group_workflow_feature_enabled(group_id): self._json(route, {"error": "The selected group or workflow sources are not allowed."}, 403) diff --git a/ui_tests/fixtures/public_members.py b/ui_tests/fixtures/public_members.py index 6f9a9c65e..e61c6d1a5 100644 --- a/ui_tests/fixtures/public_members.py +++ b/ui_tests/fixtures/public_members.py @@ -1,7 +1,7 @@ # public_members.py """ Closed M10A public workspace membership HTTP fixtures for the real production V2 SPA. -Version: 0.261.179 +Version: 0.261.310 Implemented in: 0.261.179 The fixture serves the native public membership family the V2 Members section reads and writes @@ -473,10 +473,11 @@ def _list_query(self, entry): role = (entry.query.get("role") or [None])[0] if role is not None and role not in POLICY.PUBLIC_MEMBER_ROLES: raise _invalid("The role must be Owner, Admin, DocumentManager or User.") - page = self._whole_number(entry.query.get("page"), default=1, maximum=MAX_PAGE, - message=f"The page must be a whole number from 1 to {MAX_PAGE}.") - page_size = self._whole_number(entry.query.get("page_size"), default=DEFAULT_PAGE_SIZE, maximum=MAX_PAGE_SIZE, - message=f"The page size must be a whole number from 1 to {MAX_PAGE_SIZE}.") + # The composite journey also inherits the directory's unrelated tuple-returning parser. + page = PublicMembersFixture._whole_number(entry.query.get("page"), default=1, maximum=MAX_PAGE, + message=f"The page must be a whole number from 1 to {MAX_PAGE}.") + page_size = PublicMembersFixture._whole_number(entry.query.get("page_size"), default=DEFAULT_PAGE_SIZE, maximum=MAX_PAGE_SIZE, + message=f"The page size must be a whole number from 1 to {MAX_PAGE_SIZE}.") return search, role, page, page_size @staticmethod diff --git a/ui_tests/fixtures/public_workspace.py b/ui_tests/fixtures/public_workspace.py index 58b580a97..6e2dc78ea 100644 --- a/ui_tests/fixtures/public_workspace.py +++ b/ui_tests/fixtures/public_workspace.py @@ -1,7 +1,7 @@ # public_workspace.py """ Closed HTTP fixtures for the real V2 public workspace shell. -Version: 0.261.185 +Version: 0.261.310 Implemented in: 0.261.132 Every context carries the server's document_management hint, as build_public_workspace_context sends it: 0.261.167 @@ -22,6 +22,7 @@ """ import copy +import re from urllib.parse import unquote, urlsplit import pytest @@ -371,6 +372,38 @@ def _dispatch(self, route, entry): self.unexpected_requests.append(f"{method} {path} ({leak} from a public page)") self._json(route, {"error": "Personal-scope reads are not available on public pages."}, 500) return + match = re.fullmatch( + r"/api/public-workspaces/([^/]+)/(documents/facets|documents/tags|prompts|membership/members|identities|file-sources)", + path, + ) + if method == "GET" and match: + workspace_id, resource = match.groups() + assert workspace_id in self.workspaces + if resource == "documents/facets": + payload = { + "total": 0, "untagged": 0, "processing": 0, "errors": 0, + "recent": 0, "shared_with_me": 0, "by_tag": {}, "by_classification": {}, + } + elif resource == "documents/tags": + payload = {"tags": []} + elif resource in ("prompts", "membership/members") and entry.query.get("page_size") == ["1"]: + payload = {"page": 1, "page_size": 1, "total_count": 0} + if resource == "prompts": + payload["prompts"] = [] + else: + payload.update({ + "members": [], + "membership_management": {"schema_version": 1, "operations": []}, + }) + elif resource == "identities": + payload = {"identities": []} + elif resource == "file-sources": + payload = {"file_sources": []} + else: + super()._dispatch(route, entry) + return + self._json(route, payload) + return if path == "/api/public_workspaces/directory" and method == "GET": # The V2 picker and settings tab now share the native directory route (M9A commit 4). # This suite only needs the request answered with a valid page; the directory page's own diff --git a/ui_tests/test_v2_shared_workspace_overview_counts.py b/ui_tests/test_v2_shared_workspace_overview_counts.py new file mode 100644 index 000000000..7f3bcc9e4 --- /dev/null +++ b/ui_tests/test_v2_shared_workspace_overview_counts.py @@ -0,0 +1,270 @@ +# test_v2_shared_workspace_overview_counts.py +""" +Real-SPA regression tests for group/public overview counts. +Version: 0.261.310 +Implemented in: 0.261.310 + +Uses the established Azure Playwright-compatible connection fixture and closed +HTTP boundary; validates totals, zero/failure badges, scope switches and refresh. +""" + +import re + +import pytest +from playwright.sync_api import expect + +from ui_tests.fixtures.group_workspace import GroupWorkspaceFixture, group_context +from ui_tests.fixtures.public_workspace import PublicWorkspaceFixture, public_context +from ui_tests.fixtures.workspace_authoring import connect_options # noqa: F401 + + +pytestmark = pytest.mark.ui + +LABELS = { + "documents": "Documents", "tags": "Tags", "prompts": "Prompts", "sync": "File sources", + "agents": "Agents", "actions": "Actions", "workflows": "Workflows", + "identities": "Identities", "endpoints": "Endpoints", "members": "Members", +} +TOTALS = { + "documents": 17, "tags": 2, "prompts": 1001, "sync": 3, "agents": 4, + "actions": 5, "workflows": 8, "identities": 6, "endpoints": 7, "members": 1201, +} + + +class OverviewCounts: + """Count responses keep paginated totals distinct from their first page.""" + + def _init_counts(self, kind): + self.kind = kind + self.counts = { + self.first_id: dict(TOTALS), + self.second_id: {**TOTALS, "documents": 29, "prompts": 502, "members": 41}, + } + self.malformed_counts = set() + + def _dispatch(self, route, entry): + if entry.method != "GET": + super()._dispatch(route, entry) + return + path = entry.path + match = re.fullmatch( + r"/api/(?:groups|public-workspaces)/([^/]+)/(documents(?:/facets|/tags)?|prompts|membership/members|identities|file-sources|agents|actions|model-endpoints)", + path, + ) + if match: + identifier, resource = match.groups() + elif path.startswith("/api/group_documents"): + identifier = entry.query.get("group_id", [""])[0] + resource = "documents" + path.removeprefix("/api/group_documents") + elif path == "/api/group/workflows": + identifier = entry.query.get("group_id", [""])[0] + resource = "workflows" + else: + super()._dispatch(route, entry) + return + if identifier not in self.counts: + super()._dispatch(route, entry) + return + key = { + "documents/facets": "documents", "documents/tags": "tags", + "file-sources": "sync", "membership/members": "members", "model-endpoints": "endpoints", + }.get(resource, resource) + if (identifier, key) in self.malformed_counts: + self._json(route, {"unexpected": True}) + return + total = self.counts[identifier][key] + row = {"id": "item", "group_id": identifier, "public_workspace_id": identifier} + if resource == "documents/facets": + payload = { + "total": total, "untagged": total, "processing": 0, "errors": 0, "recent": 0, + "shared_with_me": 0, "by_tag": {}, "by_classification": {}, + } + elif resource == "documents/tags": + payload = {"tags": [{"name": f"tag-{index}", "count": 1} for index in range(total)]} + elif resource == "documents": + payload = { + "documents": [], "total_count": total, + "page": int(entry.query.get("page", ["1"])[0]), + "page_size": int(entry.query.get("page_size", ["1"])[0]), + } + elif resource == "prompts": + payload = { + "prompts": [{**row, "public_id": identifier}] if total else [], + "total_count": total, "page": 1, "page_size": 1, + } + elif resource == "membership/members": + payload = { + "members": [{ + "userId": "owner", "displayName": "Owner", "email": "", + "role": "Owner", "member_actions": [], + }] if total else [], + "total_count": total, "page": 1, "page_size": 1, + "membership_management": {"schema_version": 1, "operations": []}, + } + else: + array_key = { + "file-sources": "file_sources", "model-endpoints": "endpoints", + }.get(resource, resource) + payload = {array_key: [ + { + **row, "id": f"item-{index}", "config_revision": "revision", + "source_actions": [], "revision": "revision", "endpoint_actions": [], + } + for index in range(total) + ]} + self._json(route, payload) + + +class GroupOverviewFixture(OverviewCounts, GroupWorkspaceFixture): + first_id = "group-a" + second_id = "group-b" + + def __init__(self, page): + super().__init__(page) + self.active_group = self.first_id + self._init_counts("group") + + +class PublicOverviewFixture(OverviewCounts, PublicWorkspaceFixture): + first_id = "pub-a" + second_id = "pub-b" + + def __init__(self, page): + super().__init__(page) + self.workspaces[self.first_id] = public_context(self.first_id, "Research library", role="Owner", file_sync=True) + self.active_workspace = self.first_id + self._init_counts("public") + + +@pytest.fixture(params=["group", "public"]) +def overview_ui(request, page): + fixture = GroupOverviewFixture(page) if request.param == "group" else PublicOverviewFixture(page) + yield fixture + fixture.assert_clean() + + +def open_overview(ui, **options): + base = "groups" if ui.kind == "group" else "public" + ui.open(f"/{base}/{ui.first_id}", **options) + expect(ui.page.get_by_role("heading", name="Overview", exact=True)).to_be_visible() + + +def badge(ui, section, count): + return ui.page.get_by_label(f"{LABELS[section]} count: {count}", exact=True) + + +def nav(ui, label): + ui.page.get_by_role("navigation", name="Workspace sections", exact=True).get_by_role("link", name=label, exact=True).click() + + +@pytest.mark.parametrize("theme,width,height", [ + ("light", 1440, 900), ("dark", 1440, 900), ("light", 390, 844), ("dark", 390, 844), +]) +def test_overview_shows_available_resource_totals(overview_ui, theme, width, height): + ui = overview_ui + open_overview(ui, theme=theme, width=width, height=height) + available = list(TOTALS) if ui.kind == "group" else ["documents", "prompts", "sync", "identities", "members"] + for section in available: + expect(badge(ui, section, TOTALS[section])).to_be_visible() + expect(ui.page.get_by_role("img", name=re.compile("count unavailable$"))).to_have_count(0) + for entry in ui.requests: + if entry.path.endswith(("/prompts", "/membership/members")): + assert entry.query.get("page_size") == ["1"] + for label in ("Settings", "Activity", "Statistics"): + expect(ui.page.get_by_label(re.compile(f"^{label} count"))).to_have_count(0) + if ui.kind == "public": + expect(ui.page.get_by_label(re.compile("^Tags count"))).to_have_count(0) + for label in ("Agents", "Actions", "Workflows", "Endpoints"): + expect(ui.page.get_by_label(re.compile(f"^{label} count"))).to_have_count(0) + ui.assert_no_overflow() + + +@pytest.mark.parametrize("failure", ["http", "malformed"]) +def test_partial_failure_keeps_successful_and_zero_counts(overview_ui, failure): + ui = overview_ui + ui.counts[ui.first_id]["members"] = 0 + prefix = "groups" if ui.kind == "group" else "public-workspaces" + if failure == "http": + ui.reject_next("GET", f"/api/{prefix}/{ui.first_id}/prompts") + else: + ui.malformed_counts.add((ui.first_id, "prompts")) + open_overview(ui) + expect(ui.page.get_by_role("img", name="Prompts count unavailable", exact=True)).to_be_visible() + expect(badge(ui, "documents", 17)).to_be_visible() + expect(badge(ui, "members", 0)).to_be_visible() + expect(badge(ui, "prompts", 0)).to_have_count(0) + + +def test_switch_discards_old_workspace_counts_and_delayed_response(overview_ui): + ui = overview_ui + prefix = "groups" if ui.kind == "group" else "public-workspaces" + open_overview(ui) + expect(badge(ui, "prompts", 1001)).to_be_visible() + nav(ui, "Documents") + expect(ui.page.get_by_role("heading", name="Overview", exact=True)).to_have_count(0) + ui.defer_next("GET", f"/api/{prefix}/{ui.first_id}/prompts") + nav(ui, "Overview") + expect(badge(ui, "documents", 17)).to_be_visible() + expect(badge(ui, "members", 1201)).to_be_visible() + assert ui.pending_responses + picker = "Group workspace" if ui.kind == "group" else "Public Workspace" + ui.page.get_by_role("combobox", name=picker, exact=True).select_option(ui.second_id) + expect(badge(ui, "documents", 29)).to_be_visible() + expect(badge(ui, "prompts", 502)).to_be_visible() + ui.release_responses() + expect(badge(ui, "prompts", 1001)).to_have_count(0) + if ui.kind == "group": + expect(badge(ui, "members", 41)).to_be_visible() + else: + expect(ui.page.get_by_label(re.compile("^Members count"))).to_have_count(0) + assert not [ + entry for entry in ui.requests + if ui.second_id in entry.path and "/membership/" in entry.path + ] + expect(badge(ui, "identities", 6)).to_have_count(0) + connection_reads = [ + entry for entry in ui.requests + if ui.second_id in entry.path and entry.path.endswith(("/identities", "/file-sources")) + ] + assert not connection_reads + + +def test_focus_revalidation_refreshes_counts_and_revokes_unavailable_sections(overview_ui): + ui = overview_ui + open_overview(ui) + expect(badge(ui, "documents", 17)).to_be_visible() + expect(badge(ui, "identities", 6)).to_be_visible() + ui.counts[ui.first_id]["documents"] = 23 + contexts = ui.groups if ui.kind == "group" else ui.workspaces + contexts[ui.first_id]["sections"]["identities"].update(enabled=False, can_manage=False, reason="No longer available.") + ui.page.evaluate("window.dispatchEvent(new Event('focus'))") + expect(badge(ui, "documents", 23)).to_be_visible() + expect(badge(ui, "identities", 6)).to_have_count(0) + expect(ui.page.get_by_label("Identities (unavailable)", exact=True)).to_be_visible() + + +def test_group_return_to_overview_refreshes_after_resource_change(page): + ui = GroupOverviewFixture(page) + try: + open_overview(ui) + expect(badge(ui, "tags", 2)).to_be_visible() + nav(ui, "Tags") + expect(ui.page.get_by_role("heading", name="Tags", exact=True)).to_be_visible() + ui.counts[ui.first_id]["tags"] = 3 + nav(ui, "Overview") + expect(badge(ui, "tags", 3)).to_be_visible() + finally: + ui.assert_clean() + + +def test_group_call_agent_only_count_and_locked_card_gates(page): + ui = GroupOverviewFixture(page) + ui.groups[ui.first_id] = group_context(ui.first_id, "Research group", allow_group_plugins=False) + ui.groups[ui.first_id]["sections"]["endpoints"].update(enabled=False, can_manage=False, reason="Not enabled.") + try: + open_overview(ui) + expect(badge(ui, "actions", 1)).to_be_visible() + expect(badge(ui, "endpoints", 7)).to_have_count(0) + assert not [entry for entry in ui.requests if entry.path.endswith(("/actions", "/model-endpoints"))] + finally: + ui.assert_clean() From 214ca8c4bc9c5f0a89d77ca37e5037313c94222e Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 9 Oct 2026 09:31:08 -0400 Subject: [PATCH 2/2] Configure overview fixture scope through base constructors Address both inherited-attribute CodeQL comments while preserving default unselected scopes. Add constructor/bootstrap regressions and update version to 0.261.313. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../SHARED_WORKSPACE_OVERVIEW_COUNTS_FIX.md | 13 ++++++--- .../test_v2_shared_workspace_counts.mjs | 2 +- ui_tests/fixtures/group_workspace.py | 4 +-- ui_tests/fixtures/public_workspace.py | 4 +-- ...est_v2_shared_workspace_overview_counts.py | 27 +++++++++++++++---- 6 files changed, 38 insertions(+), 14 deletions(-) diff --git a/application/single_app/config.py b/application/single_app/config.py index ce9e108bf..aaf7ab33f 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.312" +VERSION = "0.261.313" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/docs/explanation/fixes/SHARED_WORKSPACE_OVERVIEW_COUNTS_FIX.md b/docs/explanation/fixes/SHARED_WORKSPACE_OVERVIEW_COUNTS_FIX.md index 036a2ed04..6780258fc 100644 --- a/docs/explanation/fixes/SHARED_WORKSPACE_OVERVIEW_COUNTS_FIX.md +++ b/docs/explanation/fixes/SHARED_WORKSPACE_OVERVIEW_COUNTS_FIX.md @@ -3,9 +3,9 @@ Fixed in version: **0.261.310**, recorded in `application/single_app/config.py`. -Current application version: **0.261.312** after integrating the base branch's -file-source configuration changes; the original implementation version remains -**0.261.310**. +Current application version: **0.261.313** after integrating the base branch's +file-source configuration changes and correcting review fixture initialization; +the original implementation version remains **0.261.310**. ## Issue and root cause @@ -76,6 +76,13 @@ dependencies, storage migrations, or classic-UI changes. ### Validation results +The **0.261.313** review follow-up passes the initial group/public selection +through optional keyword-only base fixture constructor arguments rather than +overwriting inherited attributes in the overview fixture constructors. Omitted +arguments retain the existing unselected defaults. The focused overview suite +passes **24** cases, including six constructor/bootstrap checks for default, +explicitly empty, and selected scopes. + - The production-reader count test passed, as did the existing personal section and group/public context runtime contracts. - The production V2 TypeScript/Vite build passed. diff --git a/functional_tests/test_v2_shared_workspace_counts.mjs b/functional_tests/test_v2_shared_workspace_counts.mjs index 29d4b5592..327d634fe 100644 --- a/functional_tests/test_v2_shared_workspace_counts.mjs +++ b/functional_tests/test_v2_shared_workspace_counts.mjs @@ -1,5 +1,5 @@ // test_v2_shared_workspace_counts.mjs -// Version: 0.261.312 +// Version: 0.261.313 // Implemented in: 0.261.310 // Executes the real scoped readers, pagination totals, partial failures and cancellation. diff --git a/ui_tests/fixtures/group_workspace.py b/ui_tests/fixtures/group_workspace.py index 27a3d854c..449010a69 100644 --- a/ui_tests/fixtures/group_workspace.py +++ b/ui_tests/fixtures/group_workspace.py @@ -1207,11 +1207,11 @@ def group_search_matches(workspace, term): class GroupWorkspaceFixture(WorkspaceAuthoringFixture): - def __init__(self, page): + def __init__(self, page, *, active_group=None): super().__init__(page) self.group_enabled = True self.viewer_id = OWNER_ID - self.active_group = None + self.active_group = active_group self.groups = { "group-a": group_context("group-a", "Research group"), "group-b": group_context("group-b", "Read-only group", role="User"), diff --git a/ui_tests/fixtures/public_workspace.py b/ui_tests/fixtures/public_workspace.py index 6e2dc78ea..eb9335a1b 100644 --- a/ui_tests/fixtures/public_workspace.py +++ b/ui_tests/fixtures/public_workspace.py @@ -324,11 +324,11 @@ def gate(enabled_flag, unavailable_reason): class PublicWorkspaceFixture(WorkspaceAuthoringFixture): - def __init__(self, page): + def __init__(self, page, *, active_workspace=None): super().__init__(page) self.public_enabled = True self.viewer_id = OWNER_ID - self.active_workspace = None + self.active_workspace = active_workspace self.workspaces = { "pub-a": public_context("pub-a", "Research library"), "pub-b": public_context("pub-b", "Read-only library"), diff --git a/ui_tests/test_v2_shared_workspace_overview_counts.py b/ui_tests/test_v2_shared_workspace_overview_counts.py index 7d8111416..a02cdc68f 100644 --- a/ui_tests/test_v2_shared_workspace_overview_counts.py +++ b/ui_tests/test_v2_shared_workspace_overview_counts.py @@ -1,7 +1,7 @@ # test_v2_shared_workspace_overview_counts.py """ Real-SPA regression tests for group/public overview counts. -Version: 0.261.312 +Version: 0.261.313 Implemented in: 0.261.310 Uses the established Azure Playwright-compatible connection fixture and closed @@ -120,8 +120,7 @@ class GroupOverviewFixture(OverviewCounts, GroupWorkspaceFixture): second_id = "group-b" def __init__(self, page): - super().__init__(page) - self.active_group = self.first_id + super().__init__(page, active_group=self.first_id) self._init_counts("group") @@ -130,12 +129,30 @@ class PublicOverviewFixture(OverviewCounts, PublicWorkspaceFixture): second_id = "pub-b" def __init__(self, page): - super().__init__(page) + super().__init__(page, active_workspace=self.first_id) self.workspaces[self.first_id] = public_context(self.first_id, "Research library", role="Owner", file_sync=True) - self.active_workspace = self.first_id self._init_counts("public") +@pytest.mark.parametrize("fixture_class,attribute,scope_key,selected_id", [ + (GroupWorkspaceFixture, "active_group", "active_group_id", "group-b"), + (PublicWorkspaceFixture, "active_workspace", "active_public_workspace_id", "pub-b"), +]) +@pytest.mark.parametrize("initial_selection", ["default", "none", "selected"]) +def test_workspace_fixture_initial_selection(page, fixture_class, attribute, scope_key, selected_id, initial_selection): + options = {} if initial_selection == "default" else { + attribute: selected_id if initial_selection == "selected" else None, + } + ui = fixture_class(page, **options) + try: + expected = selected_id if initial_selection == "selected" else None + bootstrap = ui._bootstrap() + assert getattr(ui, attribute) == expected + assert bootstrap["scope"][scope_key] == expected + finally: + ui.assert_clean() + + @pytest.fixture(params=["group", "public"]) def overview_ui(request, page): fixture = GroupOverviewFixture(page) if request.param == "group" else PublicOverviewFixture(page)