Compare commits

...
Author SHA1 Message Date
james.maesandCodex d71c601255 test: clear keyboard focus without clicking records
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 36s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Successful in 12m20s
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Successful in 23m35s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Successful in 1h20m18s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
Prevent page-center clicks from navigating away during keyboard shortcut setup.

Co-Authored-By: Codex <noreply@openai.com>
2026-11-04 19:42:25 -06:00
james.maesandCodex 74344e83e3 fix: read Tools server address from client configuration
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 11s
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 56s
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Failing after 13m50s
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Failing after 13m54s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Failing after 20s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Failing after 16m48s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 9s
Avoid treating the API base address as an empty HTTP endpoint in the UI API contract scan. Tools, widget dispatcher and contract tests pass.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-11-04 19:23:34 -06:00
james.maesandCodex 978adad7fc feat: add companion Tools setup workspace
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 32s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Failing after 10m9s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / ci-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Canceled after 11m50s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Canceled after 22m15s
Add metadata-driven Tools navigation, platform installers and connection guidance for CLI, scanner and print clients.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-11-04 18:59:06 -06:00
9 changed files with 872 additions and 3 deletions

No files matched your search

+2
View File
@@ -4,6 +4,8 @@
## Features
- **Tools:** the `QQQ_TOOLS` widget provides a shared setup workspace for CLI/TUI, mobile scanners and Print Client, with platform-specific install commands, copyable scripts and instance connection details. An account-menu shortcut follows visible Tools metadata. Requires the Tools Qbit; existing device enrollment and permissions remain unchanged.
- **UI API 1.6.0 (QQQ 4.1.0-RC.15).** Next reads the typed `clientCapabilities` of table metadata for saved views, sharing, bulk processes and audits, applies `field.validation` (required, length, inclusive and exclusive bounds, patterns) before a request, reads client-neutral `uiSettings` setting by setting with the deprecated `materialDashboard` values as fallback, and shows the process error `traceId`. The full `materialDashboardTheme` still wins over the `uiSettings.theme` subset. Error messages keep their order (`userFacingError`, `error`, then the new `message`). Audits fall back to the permission-filtered instance metadata when a table's capability is unavailable, since RC.15 marks unaudited tables unavailable although explicit audits can exist. Off-spec calls stay at 0.
- **What's new:** a paged release history with new badges, safe links and screenshots, notification deep links, and an unread count in the user menu. Uses UI API extension `release-notes` 1.0.0; requires qbit-release-notes 0.1.0-RC.1+.
- **Profile photo and avatars everywhere** (QQQ 4.1.0-RC.17, UI API 1.8.0 `avatars`):
+9 -1
View File
@@ -25,7 +25,10 @@
import React from 'react'
import Link from 'next/link'
import { useQuery } from '@tanstack/react-query'
import { Bug, ChevronDown, LogOut, Moon, Settings } from 'lucide-react'
import { Bug, ChevronDown, LogOut, Moon, Settings, Wrench } from 'lucide-react'
import { useMetaData } from '@/lib/hooks/use-metadata'
import { toolsTarget } from '@/components/tools/tools-catalog'
import { getPreferences } from '@/lib/api/preferences'
import { useMediaQuery } from '@/lib/hooks/use-media-query'
@@ -107,6 +110,8 @@ function AccountMenuItems({ displayName, userEmail, logout, close, compactItems
compactItems?: (close: (then?: () => void) => void) => React.ReactNode
}) {
const { theme, isDarkMode, setDarkMode } = useTheme()
const metadata = useMetaData()
const tools = toolsTarget(metadata.data)
const capability = useQaCaptureCapability()
const preferences = useQuery({ queryKey: queryKeys.preferencesIndex(), queryFn: getPreferences, retry: false })
const qaSection = capability?.enabled && capability.reason === 'QA_MODE' ? capability.qaMode.preferenceSection : undefined
@@ -127,6 +132,9 @@ function AccountMenuItems({ displayName, userEmail, logout, close, compactItems
<HeaderMenuSeparator />
</div>
)}
{tools && <Link href={tools.path} role="menuitem" onClick={() => close()} className={HEADER_MENU_ITEM_CLASS} data-qqq-id="menu-item-tools">
<Wrench className="h-4 w-4 text-muted-foreground" aria-hidden="true" />{tools.label}
</Link>}
<Link href="/app/preferences" role="menuitem" onClick={() => close()} className={HEADER_MENU_ITEM_CLASS} data-qqq-id="menu-item-preferences">
<Settings className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
Preferences
+189
View File
@@ -0,0 +1,189 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { axe } from 'jest-axe'
import { describe, expect, it, vi } from 'vitest'
import type { QInstance } from '@/types'
import ToolsWidget from './ToolsWidget'
import { connectionUrl, toolsTarget } from './tools-catalog'
import backendCatalog from '../../../tests/e2e/fixtures/tools-catalog.json'
const metadata = {
apps: {},
appTree: [],
widgets: {},
tables: {},
processes: {},
reports: {},
branding: {},
} as QInstance
vi.mock('@/lib/hooks/use-metadata', () => ({ useMetaData: () => ({ data: metadata }) }))
const terminal = {
id: 'terminal',
label: 'CLI & Terminal UI',
summary: 'Use QQQ from a terminal.',
platforms: 'Windows, macOS and Linux',
available: true,
downloadUrl: 'https://example.test/releases',
downloadLabel: 'Get CLI & TUI',
guideUrl: 'https://example.test/guide',
note: 'Standalone includes both.',
steps: ['Install the client.', 'Sign in.'],
commands: ['qqq login --server {server}', 'qqq ui'],
manageApp: 'printing',
}
const payload = {
serverUrl: 'https://example.test/customer',
tools: [terminal, { ...terminal, id: 'scanner', label: 'Mobile Scanners', available: false }],
}
describe('Tools', () => {
it('renders the actual Java payload with omitted empty command lists', () => {
render(<ToolsWidget data={backendCatalog} />)
expect(screen.getByRole('button', { name: 'Set up Print Client' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Set up Mobile Scanners' })).toBeInTheDocument()
})
it('renders available downloads, collapses unavailable tools and copies an instance-specific sign-in command', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const { container } = render(<ToolsWidget data={payload} />)
expect(screen.getByRole('link', { name: /Get CLI/ })).toHaveAttribute(
'href',
'https://example.test/releases'
)
expect(container.querySelector('[data-qqq-id="tools-unavailable"]')).not.toHaveAttribute('open')
expect(container.querySelector('[data-qqq-id="tools-card-scanner"] a')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Set up CLI & Terminal UI' }))
fireEvent.click(screen.getByLabelText('Copy CLI & Terminal UI command 1'))
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith('qqq login --server "https://example.test/customer"')
)
expect(screen.queryByText(/Open Printing/)).not.toBeInTheDocument()
expect(await axe(container)).toHaveNoViolations()
})
it('switches the setup workspace without hiding other tools', () => {
render(<ToolsWidget data={backendCatalog} />)
fireEvent.click(screen.getByRole('button', { name: 'Set up Print Client' }))
expect(screen.getByRole('heading', { name: 'Print Client' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Get Print Client/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Set up CLI & Terminal UI' })).toHaveAttribute(
'aria-pressed',
'false'
)
fireEvent.click(screen.getByRole('button', { name: 'Set up Mobile Scanners' }))
expect(screen.getByRole('heading', { name: 'Mobile Scanners' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /Get Print Client/ })).not.toBeInTheDocument()
})
it('switches platform installers and copies the selected command', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<ToolsWidget data={backendCatalog} />)
fireEvent.change(screen.getByLabelText('Install platform'), { target: { value: 'windows' } })
expect(screen.getByText('scoop install qqq')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Copy Scoop install command 2'))
await waitFor(() => expect(writeText).toHaveBeenCalledWith('scoop install qqq'))
fireEvent.change(screen.getByLabelText('Install platform'), { target: { value: 'macos' } })
expect(screen.getByText('brew install qrun-io/tap/qqq')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Install method'), {
target: { value: 'curl installer' },
})
expect(screen.getByText(/curl -fsSL/)).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Install platform'), { target: { value: 'linux' } })
expect(screen.getByLabelText('Install method')).toHaveValue('curl installer')
})
it('copies complete printer and mobile installer scripts with preserved newlines', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<ToolsWidget data={backendCatalog} />)
for (const [label, id] of [
['Print Client', 'printing'],
['Mobile Scanners', 'scanner'],
]) {
fireEvent.click(screen.getByRole('button', { name: `Set up ${label}` }))
fireEvent.change(screen.getByLabelText('Install platform'), { target: { value: 'windows' } })
fireEvent.click(screen.getByRole('button', { name: 'Copy install script' }))
const script = backendCatalog.tools.find((tool) => tool.id === id)!.installOptions[0]
.commands![0]
await waitFor(() => expect(writeText).toHaveBeenLastCalledWith(script))
expect(script).toContain('\n')
}
fireEvent.change(screen.getByLabelText('Install platform'), { target: { value: 'android' } })
expect(screen.queryByRole('button', { name: 'Copy install script' })).not.toBeInTheDocument()
expect(screen.getByText(/In the handheld's Files app/)).toBeInTheDocument()
})
it('shows a recovery path when clipboard access fails', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
})
render(<ToolsWidget data={payload} />)
fireEvent.click(screen.getByLabelText('Copy server address'))
expect(
await screen.findByText('Could not copy. Select and copy the text above.')
).toBeInTheDocument()
})
it('rejects malformed payloads and unsafe download links', () => {
const view = render(<ToolsWidget data={{}} />)
expect(screen.getByRole('alert')).toHaveTextContent('could not be loaded')
view.rerender(
<ToolsWidget
data={{ ...payload, tools: [{ ...terminal, downloadUrl: 'javascript:alert(1)' }] }}
/>
)
expect(screen.queryByRole('link', { name: /Get CLI/ })).not.toBeInTheDocument()
})
it('resolves public roots without losing proxy paths or accepting credentials and shell syntax', () => {
expect(connectionUrl(null, '/tenant/qqq/v1', 'https://example.test')).toBe(
'https://example.test/tenant'
)
expect(connectionUrl(null, 'https://api.test/qqq/v1/', 'https://example.test')).toBe(
'https://api.test'
)
expect(connectionUrl(null, '/unknown', 'https://example.test')).toBeUndefined()
expect(connectionUrl(null, '/qqq/v1', 'http://localhost:8077')).toBe('http://localhost:8077')
for (const value of [
'https://user:pass@example.test',
'https://example.test?token=secret',
'http://example.test',
'https://example.test/$HOME',
]) {
expect(connectionUrl(value, '/qqq/v1', 'https://example.test')).toBeUndefined()
}
})
it('finds the menu shortcut by visible widget metadata, including renamed apps', () => {
const instance = {
...metadata,
apps: { downloads: { name: 'downloads', label: 'Company Tools', widgets: ['catalog'] } },
widgets: {
catalog: { name: 'catalog', label: 'Tools', type: 'QQQ_TOOLS', hasPermission: true },
},
appTree: [{ name: 'downloads', label: 'Company Tools', type: 'APP' as const }],
}
expect(toolsTarget(instance)?.path).toBe('/app/downloads')
expect(toolsTarget({ ...instance, appTree: [] })).toBeUndefined()
instance.widgets.catalog.hasPermission = false
expect(toolsTarget(instance)).toBeUndefined()
})
})
+458
View File
@@ -0,0 +1,458 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Companion tools with a compact chooser and focused setup workspace. */
'use client'
import React, { useState } from 'react'
import Link from 'next/link'
import {
ArrowDownToLine,
ArrowUpRight,
Check,
CheckCircle2,
Copy,
Info,
Monitor,
Printer,
Smartphone,
Terminal,
Wrench,
} from 'lucide-react'
import apiClient from '@/lib/api/client'
import { useMetaData } from '@/lib/hooks/use-metadata'
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
import { connectionUrl, downloadLink, toolsCatalogSchema, type Tool } from './tools-catalog'
const actionClass =
'inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
/** Copy without breaking commands across lines.
* @param props - Text, accessible label and optional terminal treatment.
* @returns Scrollable text and clipboard feedback.
*/
function CopyValue({
value,
label,
terminal = false,
}: {
value: string
label: string
terminal?: boolean
}) {
const [status, setStatus] = useState('')
const multiline = value.includes('\n')
return (
<div className="min-w-0">
<div
className={`flex min-w-0 gap-2 ${multiline ? 'flex-col items-stretch' : 'items-center'} ${terminal ? 'py-2' : 'rounded-lg border border-border bg-background px-3 py-2'}`}
>
<code
className={`min-w-0 flex-1 max-h-64 overflow-auto whitespace-pre py-1 text-xs leading-6 ${terminal ? 'text-slate-100' : 'text-foreground'}`}
tabIndex={0}
aria-label={label}
>
{value}
</code>
<button
type="button"
aria-label={multiline ? 'Copy install script' : `Copy ${label}`}
title={`Copy ${label}`}
className={`flex h-9 shrink-0 items-center justify-center gap-2 rounded-md ${multiline ? 'w-full border border-slate-600 px-3 text-xs' : 'w-9'} transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${terminal ? 'text-slate-300 hover:bg-white/10 hover:text-white' : 'text-muted-foreground hover:bg-muted hover:text-foreground'}`}
data-qqq-id={`tools-copy-${label.replace(/ /g, '-')}`}
onClick={async () => {
try {
await navigator.clipboard.writeText(value)
setStatus('Copied')
} catch {
setStatus('Could not copy. Select and copy the text above.')
}
}}
>
{status === 'Copied' ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
{multiline &&
(status === 'Copied' ? 'Copied � paste into your terminal' : 'Copy install script')}
</button>
</div>
<span
role="status"
className={
status && status !== 'Copied'
? `block py-1 text-xs ${terminal ? 'text-slate-200' : 'text-muted-foreground'}`
: 'sr-only'
}
>
{status}
</span>
</div>
)
}
/** Stable product icon with a fallback for future catalog entries.
* @param props - Tool identifier and icon classes.
* @returns Product icon.
*/
function ToolIcon({ id, className }: { id: string; className?: string }) {
const Icon =
(
{ terminal: Terminal, scanner: Smartphone, printing: Printer } as Record<
string,
typeof Wrench
>
)[id] ?? Wrench
return <Icon className={className} aria-hidden="true" />
}
/** Platform and package-manager choices supplied by the catalog.
* @param props - Selected tool with supported install methods.
* @returns In-app installation instructions and copyable commands.
*/
function InstallInstructions({ tool }: { tool: Tool }) {
const [platform, setPlatform] = useState(() => {
const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent.toLowerCase()
return ua.includes('win')
? 'windows'
: ua.includes('mac')
? 'macos'
: ua.includes('android')
? 'android'
: 'linux'
})
const [method, setMethod] = useState('')
const options = tool.installOptions
if (options.length === 0) return null
const platforms = options.filter(
(option, index) => options.findIndex((other) => other.platform === option.platform) === index
)
const selectedPlatform = platforms.some((option) => option.platform === platform)
? platform
: platforms[0].platform
const methods = options.filter((option) => option.platform === selectedPlatform)
const selected = methods.find((option) => option.method === method) ?? methods[0]
return (
<div className="mb-6 border-b border-border pb-6" data-qqq-id="tools-install-instructions">
<h3 className="mb-3 text-sm font-semibold">Install {tool.label}</h3>
<div className="mb-3 flex flex-wrap gap-3">
<label className="flex min-w-0 flex-1 flex-col gap-1 text-xs text-muted-foreground">
Platform
<select
aria-label="Install platform"
value={selectedPlatform}
onChange={(event) => {
setPlatform(event.target.value)
setMethod('')
}}
data-qqq-id="tools-install-platform"
className="min-h-10 min-w-0 w-full rounded-lg border border-border bg-background px-3 text-sm text-foreground focus-visible:outline-ring"
>
{platforms.map((option) => (
<option key={option.platform} value={option.platform}>
{option.platformLabel}
</option>
))}
</select>
</label>
<label className="flex min-w-0 flex-1 flex-col gap-1 text-xs text-muted-foreground">
Install with
<select
aria-label="Install method"
value={selected.method}
onChange={(event) => setMethod(event.target.value)}
data-qqq-id="tools-install-method"
className="min-h-10 min-w-0 w-full rounded-lg border border-border bg-background px-3 text-sm text-foreground focus-visible:outline-ring"
>
{methods.map((option) => (
<option key={option.method}>{option.method}</option>
))}
</select>
</label>
</div>
<p className="mb-3 text-xs leading-relaxed text-muted-foreground">{selected.instructions}</p>
{selected.commands.length > 0 && (
<div className="min-w-0 overflow-hidden rounded-xl bg-slate-950 p-3">
<p className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{selected.shell}
</p>
{selected.commands.map((command, index) => (
<CopyValue
key={command}
terminal
value={command}
label={`${selected.method} install command ${index + 1}`}
/>
))}
</div>
)}
</div>
)
}
/** Render an available tool's setup in one consistently sized workspace.
* @param props - Selected tool, public server and visible management destination.
* @returns Setup panel.
*/
function SetupPanel({
tool,
server,
management,
}: {
tool: Tool
server?: string
management?: { path: string; label: string }
}) {
const download = downloadLink(tool.downloadUrl)
const guide = downloadLink(tool.guideUrl)
return (
<section
id="tools-setup-panel"
aria-labelledby="tools-setup-heading"
className="overflow-hidden rounded-2xl border border-border bg-card shadow-sm"
data-qqq-id={`tools-setup-${tool.id}`}
>
<header className="flex flex-wrap items-start justify-between gap-5 border-b border-border px-5 py-5 sm:px-6">
<div className="min-w-0 flex-1 basis-64">
<p className="mb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Setup guide
</p>
<h2 id="tools-setup-heading" className="text-xl font-semibold tracking-tight">
{tool.label}
</h2>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-muted-foreground">
{tool.summary}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{download && (
<a
className={`${actionClass} bg-primary text-primary-foreground shadow-sm hover:bg-primary/90`}
href={download}
target="_blank"
rel="noopener noreferrer"
data-qqq-id={`tools-download-${tool.id}`}
>
<ArrowDownToLine className="h-4 w-4" aria-hidden="true" />
{tool.downloadLabel}
<span className="sr-only"> (opens in a new tab)</span>
</a>
)}
{guide && (
<a
className={`${actionClass} border border-border hover:bg-muted`}
href={guide}
target="_blank"
rel="noopener noreferrer"
data-qqq-id={`tools-guide-${tool.id}`}
>
Installation guide
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
<span className="sr-only"> (opens in a new tab)</span>
</a>
)}
</div>
</header>
<div className="grid min-w-0 xl:grid-cols-2">
<div className="min-w-0 px-5 py-6 sm:px-6">
<InstallInstructions tool={tool} />
<h3 className="mb-5 text-sm font-semibold">Connect and start using it</h3>
<ol className="space-y-6">
{tool.steps.map((step, index) => (
<li key={index} className="flex gap-3">
<span
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary"
aria-hidden="true"
>
{index + 1}
</span>
<span className="pt-0.5 text-sm leading-relaxed">
<span className="sr-only">Step {index + 1}. </span>
{step}
</span>
</li>
))}
</ol>
{management && (
<Link
href={management.path}
className={`${actionClass} mt-5 border border-border hover:bg-muted`}
data-qqq-id={`tools-manage-${tool.id}`}
>
Open {management.label}
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
</Link>
)}
</div>
<div
role="group"
className="min-w-0 space-y-5 border-t border-border bg-muted/25 px-5 py-6 sm:px-6 xl:border-l xl:border-t-0"
aria-label="Connection details"
>
<div>
<h3 className="mb-1 flex items-center gap-2 text-sm font-semibold">
<Monitor className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
Connect to this instance
</h3>
<p className="mb-3 text-xs leading-relaxed text-muted-foreground">
Use this server address and sign in with your QQQ account.
</p>
{server ? (
<CopyValue key={server} value={server} label="server address" />
) : (
<p className="text-sm text-muted-foreground">
Ask your administrator for the public HTTPS server address before connecting a
client.
</p>
)}
{server && /^http:\/\//.test(server) && (
<p className="mt-2 text-xs text-muted-foreground">
Local development only. Other devices need a reachable HTTPS address.
</p>
)}
</div>
{tool.commands.length > 0 && (
<div className="min-w-0 overflow-hidden rounded-xl bg-slate-950 p-4 text-slate-100">
<div className="mb-2 flex items-center gap-2 border-b border-slate-700 pb-3 text-xs font-medium text-slate-300">
<Terminal className="h-4 w-4" aria-hidden="true" />
Terminal commands
</div>
{tool.commands.map((command, index) =>
command.includes('{server}') && !server ? null : (
<CopyValue
key={`${command}-${server}`}
terminal
label={`${tool.label} command ${index + 1}`}
value={command.replace(/\{server\}/g, `"${server}"`)}
/>
)
)}
</div>
)}
<p className="flex items-start gap-2 text-xs leading-relaxed text-muted-foreground">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
Your existing permissions apply. Each device signs in separately.
</p>
</div>
</div>
<footer className="flex items-start gap-2 border-t border-border px-5 py-4 text-xs leading-relaxed text-muted-foreground sm:px-6">
<Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
{tool.note}
</footer>
</section>
)
}
/** Render the catalog with one selected tool, keeping every available tool visible.
* @param props - Standard widget payload.
* @returns Tool chooser and setup panel, or a recoverable error.
*/
export default function ToolsWidget({ data }: { data: unknown }) {
const metadata = useMetaData()
const { navTargets } = useAppTreeRoutes(metadata.data)
const [selectedId, setSelectedId] = useState<string>()
const parsed = toolsCatalogSchema.safeParse(data)
if (!parsed.success)
return (
<p role="alert">
Tools information could not be loaded. Refresh the page or contact your administrator.
</p>
)
const catalog = parsed.data
const server =
typeof window === 'undefined'
? undefined
: connectionUrl(
catalog.serverUrl,
apiClient.getInstance().defaults.baseURL ?? '',
window.location.origin
)
const available = catalog.tools.filter((tool) => tool.available)
const unavailable = catalog.tools.filter((tool) => !tool.available)
const selected = available.find((tool) => tool.id === selectedId) ?? available[0]
return (
<div className="space-y-5" data-qqq-id="tools-catalog">
<p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">
Your workspace, beyond the browser. Choose a tool to download it and connect to this
instance.
</p>
<div className="grid gap-3 md:grid-cols-3" aria-label="Choose a tool">
{available.map((tool) => (
<button
key={tool.id}
type="button"
aria-pressed={selected?.id === tool.id}
aria-controls="tools-setup-panel"
aria-label={`Set up ${tool.label}`}
data-qqq-id={`tools-card-${tool.id}`}
onClick={() => setSelectedId(tool.id)}
className={`group flex min-w-0 items-center gap-3 rounded-xl border p-4 text-left transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring md:items-start ${selected?.id === tool.id ? 'border-primary bg-primary/5 shadow-[inset_0_0_0_1px_var(--primary)]' : 'border-border bg-card hover:border-primary/40 hover:bg-muted/30'}`}
>
<span
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${selected?.id === tool.id ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`}
>
<ToolIcon id={tool.id} className="h-5 w-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold">{tool.label}</span>
<span className="mt-1 block text-xs leading-relaxed text-muted-foreground">
{tool.platforms}
</span>
</span>
{selected?.id === tool.id && (
<Check className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
)}
</button>
))}
</div>
{selected && (
<SetupPanel
key={selected.id}
tool={selected}
server={server}
management={navTargets.find(
(target) => target.nodeType === 'APP' && target.key === selected.manageApp
)}
/>
)}
{unavailable.length > 0 && (
<details
className="rounded-xl border border-dashed border-border px-4 py-3"
data-qqq-id="tools-unavailable"
>
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">
More tools � {unavailable.length} not enabled
</summary>
<div className="mt-3 grid gap-3 md:grid-cols-3">
{unavailable.map((tool) => (
<div
key={tool.id}
className="rounded-lg bg-muted/40 p-3"
data-qqq-id={`tools-card-${tool.id}`}
>
<h2 className="text-sm font-medium">{tool.label}</h2>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Not enabled on this instance. Ask your administrator about enabling this tool.
</p>
</div>
))}
</div>
</details>
)}
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Tools catalog contract and metadata-based navigation. */
import { z } from 'zod'
import type { QInstance } from '@/types'
import { buildRouteMap } from '@/lib/hooks/use-routes'
export const toolsCatalogSchema = z.object({
serverUrl: z.string().nullish(),
tools: z.array(
z.object({
id: z.string(),
label: z.string(),
summary: z.string(),
platforms: z.string(),
available: z.boolean(),
downloadUrl: z.string().nullish(),
downloadLabel: z.string(),
guideUrl: z.string(),
manageApp: z.string().nullish(),
note: z.string(),
steps: z.array(z.string()),
commands: z.array(z.string()).default([]),
installOptions: z
.array(
z.object({
platform: z.string(),
platformLabel: z.string(),
method: z.string(),
shell: z.string().default(''),
instructions: z.string(),
commands: z.array(z.string()).default([]),
})
)
.default([]),
})
),
})
export type Tool = z.infer<typeof toolsCatalogSchema>['tools'][number]
/** Find the visible app that hosts the tools widget, independent of app naming.
* @param metadata - Permission-filtered instance metadata.
* @returns The Tools destination, if visible.
*/
export function toolsTarget(metadata: QInstance | undefined) {
return buildRouteMap(metadata).navTargets.find(
(target) =>
target.nodeType === 'APP' &&
metadata?.apps[target.key]?.widgets?.some(
(name) =>
metadata.widgets[name]?.type === 'QQQ_TOOLS' &&
metadata.widgets[name]?.hasPermission !== false
)
)
}
/** HTTPS external links only.
* @param value - Configured download or guide URL.
* @returns Safe external URL, or undefined.
*/
export function downloadLink(value: string | null | undefined): string | undefined {
if (!value) return undefined
try {
const url = new URL(value)
return url.protocol === 'https:' && !url.username && !url.password ? url.href : undefined
} catch {
return undefined
}
}
/** Resolve the client-facing server root, retaining reverse-proxy path prefixes.
* @param configured - Optional explicit public server root.
* @param apiBase - Configured UI API base.
* @param origin - Browser origin for relative API paths.
* @returns Safe server root, or undefined when configuration is ambiguous.
*/
export function connectionUrl(
configured: string | null | undefined,
apiBase: string,
origin: string
): string | undefined {
try {
const url = new URL(configured || apiBase, origin)
if (!configured) {
if (!url.pathname.replace(/\/+$/, '').endsWith('/qqq/v1')) return undefined
url.pathname = url.pathname.replace(/\/qqq\/v1\/*$/, '')
}
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) return undefined
if (url.username || url.password || url.search || url.hash) return undefined
const value = url.href.replace(/\/+$/, '')
// Safe inside quoted shell commands on Windows, macOS and Linux.
return /^https?:\/\/[A-Za-z0-9.:/_[\]~%+-]+$/.test(value) ? value : undefined
} catch {
return undefined
}
}
@@ -77,6 +77,7 @@ const LineChartWidget = lazy(() => import('./LineChartWidget').then((m) => ({ de
const PieChartWidget = lazy(() => import('./PieChartWidget').then((m) => ({ default: m.PieChartWidget })))
const FilterAndColumnsSetupWidget = lazy(() => import('./FilterAndColumnsSetupWidget').then((m) => ({ default: m.FilterAndColumnsSetupWidget })))
const PivotTableSetupWidget = lazy(() => import('./PivotTableSetupWidget').then((m) => ({ default: m.PivotTableSetupWidget })))
const ToolsWidget = lazy(() => import('@/components/tools/ToolsWidget'))
const SqlWorkbenchWidget = lazy(() => import('@/components/sql-workbench/SqlWorkbenchWidget'))
const AuthentikPolicyEditorWidget = lazy(() => import('@/components/authentik/AuthentikPolicyEditorWidget'))
const RowBuilderWidget = lazy(() => import('./RowBuilderWidget').then((m) => ({ default: m.RowBuilderWidget })))
@@ -152,6 +153,8 @@ export function WidgetRenderer({ widgetMetaData, data, recordContext, formContex
}
switch (resolvedType) {
case 'QQQ_TOOLS':
return <Suspense fallback={SetupFallback}><ToolsWidget data={data} /></Suspense>
case 'statistics':
return 'count' in data
? <QqqStatisticsWidget {...common} data={data} />
+1
View File
@@ -0,0 +1 @@
{"serverUrl":"https://example.test/customer","tools":[{"id":"terminal","label":"CLI & Terminal UI","summary":"Work with your QQQ apps from a command line or an interactive terminal.","platforms":"Windows, macOS and Linux","available":true,"downloadUrl":"https://github.com/QRun-IO/qqq-cli/releases","downloadLabel":"Get CLI & TUI","guideUrl":"https://github.com/QRun-IO/qqq-cli#install","note":"Download the standalone binary for both CLI and TUI. The npm package includes only the CLI.","steps":["Sign in to this instance using the command below. Follow the sign-in prompts on your computer.","Run qqq ui for the terminal app, or qqq --help for command-line options."],"commands":["qqq login --server {server}","qqq whoami","qqq ui"],"installOptions":[{"platform":"windows","platformLabel":"Windows","method":"Scoop","shell":"PowerShell","instructions":"With Scoop installed, run these commands in PowerShell. This installs the CLI and terminal UI together.","commands":["scoop bucket add qrun https://github.com/QRun-IO/scoop-bucket","scoop install qqq"]},{"platform":"macos","platformLabel":"macOS","method":"Homebrew","shell":"Terminal","instructions":"With Homebrew installed, run this command. It installs the CLI, terminal UI and shell completions.","commands":["brew install qrun-io/tap/qqq"]},{"platform":"macos","platformLabel":"macOS","method":"curl installer","shell":"Terminal","instructions":"No package manager required. Installs the CLI and terminal UI into ~/.local/bin and checks the release SHA-256 checksum. Ensure ~/.local/bin is on your PATH.","commands":["curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh"]},{"platform":"linux","platformLabel":"Linux","method":"curl installer","shell":"Shell","instructions":"No package manager required. Installs into ~/.local/bin and checks the release SHA-256 checksum. Ensure ~/.local/bin is on your PATH. On Alpine, install libstdc++ and libgcc first.","commands":["curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh"]},{"platform":"linux","platformLabel":"Linux","method":"Homebrew","shell":"Shell","instructions":"If you already use Homebrew on Linux, this installs the CLI, terminal UI and shell completions.","commands":["brew install qrun-io/tap/qqq"]}]},{"id":"scanner","label":"Mobile Scanners","summary":"Capture barcodes with an approved Android handheld and your QQQ scanning apps.","platforms":"Supported Android handhelds","available":true,"downloadUrl":"https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0","downloadLabel":"Get signed preview APK","guideUrl":"https://git.kof22.com/QRun-IO/qqq-android/src/branch/main/docs/INSTALLATION.md","manageApp":"mobileScannerAdmin","note":"Signed preview 1.0.0, not a stable release. Forge sign-in is required to download. Devices running a development-signed build need a planned migration; never uninstall or reset to bypass an installation refusal.","steps":["Enter this instance's server address and sign in with your own QQQ account.","Enroll the device and ask an administrator to approve it. Open an available scanning app after approval."],"installOptions":[{"platform":"windows","platformLabel":"Windows computer","method":"USB install - signed preview 1.0.0","shell":"PowerShell","instructions":"For a USB install, connect one handheld to this computer. On the handheld, open Settings > About phone, tap Build number seven times, then enable USB debugging in Developer options. Unlock it and accept the computer trust prompt. The script downloads Google's Platform Tools, opens the exact signed-preview release, verifies the downloaded APK and installs it. Forge login and APK download happen in your browser. Open PowerShell, paste the whole block and follow its prompts.","commands":["# QQQ Mobile signed preview 1.0.0 - run this whole block in PowerShell.\n# Connect ONE handheld by USB, enable USB debugging and accept its trust prompt.\n& {\n $ErrorActionPreference = 'Stop'\n $release = 'https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0'\n $expected = 'EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51'\n $folder = Join-Path ([IO.Path]::GetTempPath()) ('qqq-mobile-' + [guid]::NewGuid())\n New-Item -ItemType Directory -Path $folder | Out-Null\n Invoke-WebRequest 'https://dl.google.com/android/repository/platform-tools-latest-windows.zip' -OutFile \"$folder\\tools.zip\" -UseBasicParsing\n Expand-Archive -LiteralPath \"$folder\\tools.zip\" -DestinationPath $folder\n $adb = Join-Path $folder 'platform-tools\\adb.exe'\n Start-Process $release\n Write-Host 'Sign in on the release page and download QQQ-Mobile-1.0.0.apk to Downloads.'\n Read-Host 'After the download finishes, press Enter' | Out-Null\n $apk = Join-Path $env:USERPROFILE 'Downloads\\QQQ-Mobile-1.0.0.apk'\n if(-not (Test-Path -LiteralPath $apk)) {\n $apk = (Read-Host 'Paste the full path of the downloaded QQQ-Mobile-1.0.0.apk').TriLine truncated
+5 -2
View File
@@ -22,8 +22,11 @@ test.describe('Keyboard shortcuts', () => {
await page.route('**/qqq/v1/preferences/keyboard', (route) => route.fulfill({ status: 404, json: { error: 'No keyboard section' } }))
await page.goto('/app/person')
await waitForAppReady(page)
// Ensure no input is focused so single-key shortcuts fire
await page.locator('body').click()
// Clear focus without clicking a record at the center of the page.
await page.evaluate(() => {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
})
await expect(page).toHaveURL(/\/app\/person\/?$/)
})
// ── '.' key — opens command palette ──────────────────────────────────────
+94
View File
@@ -0,0 +1,94 @@
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
import catalog from './fixtures/tools-catalog.json'
import { METADATA, setupApiMocks } from './api-mocks'
for (const width of [1440, 390]) {
test(`Tools is discoverable and usable at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 })
await setupApiMocks(page)
const app = {
name: 'qqqTools',
label: 'Tools',
widgets: ['qqqToolsCatalog'],
children: [],
sections: [],
}
await page.route(/\/qqq\/v1\/metaData(?:\?.*)?$/, (route) =>
route.fulfill({
json: {
...METADATA,
apps: { ...METADATA.apps, qqqTools: app },
appTree: [
...METADATA.appTree,
{ name: app.name, label: app.label, type: 'APP', iconName: 'build' },
],
widgets: {
qqqToolsCatalog: {
name: 'qqqToolsCatalog',
label: 'Take QQQ with you',
type: 'QQQ_TOOLS',
hasPermission: true,
gridColumns: 12,
isCard: false,
},
},
},
})
)
await page.route('**/qqq/v1/widget/qqqToolsCatalog**', (route) =>
route.fulfill({ json: catalog })
)
await page.goto('/app')
await page.locator('[data-qqq-id="button-account"]').click()
await page.getByRole('menuitem', { name: 'Tools', exact: true }).click()
await expect(page).toHaveURL(/\/app\/qqqTools$/)
await expect(
page.getByRole('heading', { name: 'CLI & Terminal UI', exact: true })
).toBeVisible()
await expect(
page.getByRole('button', { name: 'Set up Mobile Scanners', exact: true })
).toBeVisible()
await expect(
page.getByRole('button', { name: 'Set up Print Client', exact: true })
).toBeVisible()
await page.getByRole('button', { name: 'Set up Print Client', exact: true }).click()
await expect(page.getByRole('heading', { name: 'Print Client', exact: true })).toBeVisible()
await expect(page.locator('[data-qqq-id="tools-download-printing"]')).toBeVisible()
await expect(page.getByRole('button', { name: 'Copy install script' })).toBeVisible()
await page.getByRole('button', { name: 'Set up Mobile Scanners', exact: true }).click()
await page.getByLabel('Install platform').selectOption('windows')
await expect(page.getByRole('button', { name: 'Copy install script' })).toBeVisible()
await expect(page.locator('[data-qqq-id="tools-download-scanner"]')).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBeTruthy()
expect(
(await new AxeBuilder({ page }).include('[data-qqq-id="tools-catalog"]').analyze()).violations
).toEqual([])
await page.screenshot({
path: test.info().outputPath(`mobile-setup-${width}.png`),
fullPage: true,
})
await page.getByRole('button', { name: 'Set up CLI & Terminal UI', exact: true }).click()
await expect(
page.getByText('qqq login --server "https://example.test/customer"', { exact: true })
).toBeVisible()
await page.getByLabel('Install platform').selectOption('windows')
await expect(page.getByText('scoop install qqq', { exact: true })).toBeVisible()
await page.getByLabel('Install platform').selectOption('macos')
await expect(page.getByText('brew install qrun-io/tap/qqq', { exact: true })).toBeVisible()
await page.getByLabel('Install platform').selectOption('linux')
await expect(page.getByText(/curl -fsSL/)).toBeVisible()
await page.getByLabel('Install platform').selectOption('windows')
await expect(page.locator('[data-qqq-id="tools-manage-printing"]')).toHaveCount(0)
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBeTruthy()
const accessibility = await new AxeBuilder({ page })
.include('[data-qqq-id="tools-catalog"]')
.analyze()
expect(accessibility.violations).toEqual([])
await page.screenshot({ path: test.info().outputPath(`tools-${width}.png`), fullPage: true })
})
}