Compare commits

..
Author SHA1 Message Date
james.maesandCodex f1dbb4b148 test: make standalone fixtures portable on Windows
Use native path separators and retain POSIX permission assertions on platforms implementing those semantics. Preserve staged-content and rewrite-denial assertions.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 08:38:49 -05:00
james.maesandCodex cab6f79255 merge: integrate assistant conversations and attachments
Preserve chrome accessibility and current conversation controls. Validation: 3658 unit tests and coverage, 27 production browser regressions, typecheck, lint, production build, Storybook and static export passed. Existing report-only size warnings remain.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 08:38:48 -05:00
james.maesandCodex a7cca3162d fix(assistant): keep header tooltips above the mobile dialog
Co-Authored-By: Codex <noreply@openai.com>
2026-10-19 07:18:03 -05:00
james.maes 955f310539 Merge branch 'main' into feature/assistant-panel-chrome 2026-10-19 07:06:27 -05:00
james.maesandClaude Opus 5.5 6e1142553b feat(assistant): clearer panel header buttons and wrapping example prompts
- History icon and "Conversation history" label for the rail toggle
- Hover and keyboard-focus tooltips plus aria-labels on history, new and close
- Example prompts wrap inside full-width chips (no clipping), stack when narrow
- Unit tests with axe, and a width e2e spec

Part of KofTwentyTwo/Website-Backend#303

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:35:06 -05:00
17 changed files with 833 additions and 87 deletions

No files matched your search

+76 -10
View File
@@ -1,14 +1,80 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
qqq-orb: kingsrook/qqq-orb@0.6
# ─── Jobs ────────────────────────────────────────────────────────────────────
# The dashboard jar (static export under next-dashboard/) is published to Maven
# Central on release tags, with the same orb commands and credentials as the
# Material Dashboard. QApplicationJavalinServer serves it at / when it is present.
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job; avoid the interactive needrestart prompt.
sudo sed -i 's|http://security.ubuntu.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
jobs:
publish_dashboard_jar:
executor: qqq-orb/java
steps:
- configure-package-install
- checkout
- qqq-orb/install_java:
install_node: true
node_version: "22"
- run:
name: Verify the immutable release version
command: |
release_version="${CIRCLE_TAG#v}"
frontend_version=$(node -p "require('./package.json').version")
maven_version=$(mvn -B -q help:evaluate -Dexpression=project.version -DforceStdout)
test "$frontend_version" = "$release_version"
test "$maven_version" = "$release_version"
- run:
name: Build the static export
command: |
corepack enable
pnpm install --frozen-lockfile
pnpm typecheck
pnpm lint
pnpm test
pnpm build:export
- qqq-orb/mvn_jar_deploy
# ─── Workflows ───────────────────────────────────────────────────────────────
# Trunk-based: main is the only long-lived branch (see CONTRIBUTING.md).
# test_only → every branch except main (the PR check ci/circleci: qqq-orb/node_app_test_only)
# publish_release → main builds; v* version tags publish the dashboard jar
workflows:
setup:
test_only:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: /.*/
tags:
only: /.*/
- qqq-orb/node_app_test_only:
filters:
branches:
ignore: main
tags:
only: []
publish_release:
jobs:
- qqq-orb/node_app_build:
filters:
branches:
only: [ main ]
tags:
only: [ /v.*/ ]
- publish_dashboard_jar:
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /.*/
tags:
only: /^v[0-9]+\.[0-9]+\.[0-9]+(-RC\.[0-9]+)?$/
+281
View File
@@ -0,0 +1,281 @@
name: Real-backend acceptance
# One production build feeds independent, serial-worker backends. Require "acceptance gate".
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
inputs:
qqq_ref:
description: QRun-IO/qqq branch or tag to build the sample backend from
required: false
permissions:
contents: read
concurrency:
group: acceptance-${{ github.ref }}
cancel-in-progress: true
env:
QQQ_REF: ${{ inputs.qqq_ref || vars.QQQ_ACCEPTANCE_REF || 'develop' }}
NEXT_TELEMETRY_DISABLED: '1'
MAVEN_ARGS: -Dmaven.wagon.http.retryHandler.count=5 -Daether.transport.http.retryHandler.count=5 -Daether.connector.http.retryHandler.count=5
jobs:
plan:
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
mode: ${{ steps.select.outputs.mode }}
matrix: ${{ steps.select.outputs.matrix }}
specs: ${{ steps.select.outputs.specs }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Select projects, shards and changed areas
id: select
run: node scripts/acceptance-select.mjs
build:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: QRun-IO/qqq
ref: ${{ env.QQQ_REF }}
path: qqq-backend
- name: Resolve QQQ source revision
id: qqq
run: echo "sha=$(git -C qqq-backend rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: temurin
java-version: '21'
cache: maven
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: qqq-cache
with:
path: ~/.m2/repository/com/kingsrook
key: qqq-source-${{ runner.os }}-java21-v1-${{ steps.qqq.outputs.sha }}
- run: corepack enable
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Bundled UI API document matches the server's (src/lib/api/openapi/qqq-v1.json)
run: node scripts/check-ui-api-spec.mjs --server-spec qqq-backend/qqq-middleware-javalin/src/main/resources/openapi/v1/openapi.yaml
- name: Build QQQ from source
if: steps.qqq-cache.outputs.cache-hit != 'true'
run: bash scripts/ci/retry.sh 4 mvn -B -q -f qqq-backend/pom.xml install -DskipTests -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true -Djacoco.skip=true
- name: Build and install the dashboard jar (static export)
run: |
# Equivalent to build:jar, with retries around its Maven invocation too.
pnpm build:export
bash scripts/ci/retry.sh 4 mvn -B -q clean package
bash scripts/ci/retry.sh 4 mvn -B -q install
# Failed-attempt Maven stdout must not become part of the version value.
bash scripts/ci/retry.sh 4 mvn -B -q help:evaluate -Dexpression=project.version -Doutput="$RUNNER_TEMP/acceptance-next-version"
next_version=$(cat "$RUNNER_TEMP/acceptance-next-version")
echo "NEXT_JAR_VERSION=$next_version" >> "$GITHUB_ENV"
- name: Build the sample application jar
working-directory: qqq-backend
run: |
# QQQ checkstyle/pmd paths are relative to this checkout.
bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" 4 mvn -B -q -f qqq-sample-project/pom.xml package -DskipTests -Djacoco.skip=true \
-Dqqq.frontend.next.version="$NEXT_JAR_VERSION"
- name: Stage runtime artifacts
run: |
sample_jars=(qqq-backend/qqq-sample-project/target/*-jar-with-dependencies.jar)
test "${#sample_jars[@]}" -eq 1
sample_version="${sample_jars[0]##*/}"
sample_version="${sample_version#qqq-sample-project-}"
sample_version="${sample_version%-jar-with-dependencies.jar}"
mkdir -p test-results/acceptance-jars
cp "${sample_jars[0]}" test-results/acceptance-jars/
# javac and the security fixture need the API JAR in addition to the fat sample JAR.
cp "$HOME/.m2/repository/com/kingsrook/qqq/qqq-middleware-api/$sample_version/qqq-middleware-api-$sample_version.jar" \
test-results/acceptance-jars/qqq-middleware-api.jar
tar -cf test-results/acceptance-build.tar out test-results/acceptance-jars
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: acceptance-build
path: test-results/acceptance-build.tar
if-no-files-found: error
retention-days: 30
shards:
name: acceptance ${{ matrix.project }} ${{ matrix.shard }}/${{ matrix.total }}
needs: [plan, build]
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
runs-on: ubuntu-24.04
timeout-minutes: 40
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: temurin
java-version: '21'
cache: maven
- run: corepack enable
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Installed Playwright version
id: playwright
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: browser-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}-${{ matrix.browser }}
- run: pnpm exec playwright install-deps ${{ matrix.browser }}
- if: steps.browser-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install ${{ matrix.browser }}
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: acceptance-build
path: test-results
- name: Restore runtime artifacts
run: |
tar -xf test-results/acceptance-build.tar
echo "QQQ_SAMPLE_JAR=$(ls "$PWD"/test-results/acceptance-jars/*-jar-with-dependencies.jar)" >> "$GITHUB_ENV"
echo "QQQ_MIDDLEWARE_API_JAR=$PWD/test-results/acceptance-jars/qqq-middleware-api.jar" >> "$GITHUB_ENV"
- name: Run shard
env:
QQQ_ACCEPTANCE_BROWSERS: ${{ matrix.project }}
QQQ_ACCEPTANCE_SPEC_PATHS: ${{ needs.plan.outputs.specs }}
QQQ_ACCEPTANCE_BLOB: '1'
# Environment-selected projects must not produce colliding default blob names.
QQQ_ACCEPTANCE_BLOB_NAME: report-${{ matrix.project }}-${{ matrix.shard }}.zip
QQQ_ACCEPTANCE_TRANSPORT_TRACE: '1'
QQQ_ACCEPTANCE_BROWSER_TRACE: ${{ (matrix.project == 'webkit' || matrix.project == 'mobile' || matrix.project == 'tablet') && 'navigation' || '0' }}
DEBUG: pw:browser
run: node scripts/acceptance.mjs --skip-build --shard=${{ matrix.shard }}/${{ matrix.total }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: acceptance-blob-${{ matrix.project }}-${{ matrix.shard }}
path: test-results/acceptance/blob-report/
if-no-files-found: error
retention-days: 30
gate:
name: acceptance gate
if: always()
needs: [plan, build, shards]
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: corepack enable
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: acceptance-blob-*
path: test-results/acceptance/blobs
merge-multiple: true
- name: Merge shard reports
env:
PLAYWRIGHT_JSON_OUTPUT_NAME: test-results/acceptance/report.json
PLAYWRIGHT_HTML_OUTPUT_DIR: test-results/acceptance/html
PLAYWRIGHT_HTML_OPEN: never
run: npx playwright merge-reports --reporter=json,html test-results/acceptance/blobs
- name: Evaluate acceptance matrix
if: always()
env:
ACCEPTANCE_MODE: ${{ needs.plan.outputs.mode }}
run: |
args=()
if [[ "$ACCEPTANCE_MODE" == pr ]]; then args+=(--partial); fi
node scripts/acceptance-gate.mjs "${args[@]}"
- name: Require every planned shard and successful build
if: always()
env:
PLAN_RESULT: ${{ needs.plan.result }}
BUILD_RESULT: ${{ needs.build.result }}
SHARDS_RESULT: ${{ needs.shards.result }}
PLANNED_MATRIX: ${{ needs.plan.outputs.matrix }}
run: |
test "$PLAN_RESULT" = success
test "$BUILD_RESULT" = success
test "$SHARDS_RESULT" = success
node --input-type=module -e 'import { existsSync } from "node:fs"; for (const { project, shard } of JSON.parse(process.env.PLANNED_MATRIX).include) { const file = "test-results/acceptance/blobs/report-" + project + "-" + shard + ".zip"; if (!existsSync(file)) throw new Error("Missing shard report: " + file); }'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: acceptance
path: test-results/acceptance/
if-no-files-found: error
retention-days: 30
quarantine:
name: acceptance quarantine (chromium)
needs: [plan, build]
if: needs.plan.outputs.mode == 'full'
continue-on-error: true
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: temurin
java-version: '21'
cache: maven
- run: corepack enable
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Installed Playwright version
id: playwright
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: browser-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}-chromium
- run: pnpm exec playwright install-deps chromium
- if: steps.browser-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install chromium
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: acceptance-build
path: test-results
- name: Restore runtime artifacts
run: |
tar -xf test-results/acceptance-build.tar
echo "QQQ_SAMPLE_JAR=$(ls "$PWD"/test-results/acceptance-jars/*-jar-with-dependencies.jar)" >> "$GITHUB_ENV"
echo "QQQ_MIDDLEWARE_API_JAR=$PWD/test-results/acceptance-jars/qqq-middleware-api.jar" >> "$GITHUB_ENV"
- name: Run quarantined scenarios
env:
QQQ_ACCEPTANCE_BROWSERS: chromium
QQQ_ACCEPTANCE_QUARANTINE: only
QQQ_ACCEPTANCE_TRANSPORT_TRACE: '1'
QQQ_ACCEPTANCE_BROWSER_TRACE: '0'
DEBUG: pw:browser
run: node scripts/acceptance.mjs --skip-build
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: acceptance-quarantine
path: test-results/acceptance/
retention-days: 30
+153 -36
View File
@@ -1,41 +1,158 @@
name: Next centralized CI
'on':
name: CI
# Fast PR checks (QRun-IO/qqq#709, bundle budget #710). The real-backend gate, including the
# runtime performance budgets (specs/performance), runs in acceptance.yml.
# Each job installs on its own so they run in parallel; none needs a backend.
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
push:
branches:
- main
tags:
- v*
workflow_dispatch:
inputs:
qqq_ref:
description: QQQ source ref for acceptance
type: string
required: false
branches: [main]
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
NEXT_TELEMETRY_DISABLED: '1'
STORYBOOK_DISABLE_TELEMETRY: '1'
jobs:
ci:
if: (github.event_name == 'pull_request' && !contains(fromJSON('["labeled","unlabeled"]'),
github.event.action)) || (github.event_name == 'push' && github.ref == 'refs/heads/main')
uses: QRun-IO/qqq-orb/.github/workflows/next-ci.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
acceptance:
if: github.event_name != 'pull_request' || !contains(fromJSON('["labeled","unlabeled"]'),
github.event.action) || github.event.label.name == 'full-acceptance'
uses: QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
with:
qqq_ref: ${{ inputs.qqq_ref || '' }}
dashboard-image:
if: (github.event_name == 'pull_request' && !contains(fromJSON('["labeled","unlabeled"]'),
github.event.action)) || (github.event_name == 'push' && startsWith(github.ref,
'refs/tags/v'))
permissions:
contents: read
packages: write
uses: QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
unit:
name: Typecheck, lint, unit tests and coverage
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: corepack enable
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: pnpm
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- name: Unit tests with src/lib coverage thresholds (vitest.config.ts)
run: pnpm test:coverage
e2e:
name: Mocked e2e (Playwright, chromium)
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: corepack enable
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: pnpm
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
- name: Installed Playwright version
id: playwright
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: browser-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}-chromium
- run: pnpm exec playwright install-deps chromium
- if: steps.browser-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install chromium
- name: Production build
run: pnpm build
- name: Mocked e2e suite
env:
QQQ_E2E_SERVER: production
run: pnpm test:e2e
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: failure()
with:
name: e2e-report
path: |
playwright-report/
test-results/
retention-days: 14
collaboration-e2e:
name: Collaboration e2e (MSW; chromium, firefox, webkit, mobile, tablet)
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: corepack enable
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: pnpm
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
- name: Installed Playwright version
id: playwright
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: browser-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}-all
- run: pnpm exec playwright install-deps chromium firefox webkit
- if: steps.browser-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install chromium firefox webkit
- name: Collaboration suite (mock API, every browser project)
run: pnpm exec playwright test --config playwright.collaboration.config.ts
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: collaboration-e2e
path: |
test-results/collaboration/
test-results/collab-shots/
retention-days: 14
storybook:
name: Storybook build
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: corepack enable
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: pnpm
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
- run: pnpm build-storybook --quiet
performance:
name: Bundle budget (static export)
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: corepack enable
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: pnpm
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
- name: Static export
run: pnpm build:export
- name: First-load JS per route, total JS and largest chunk against perf-budget.json
run: pnpm perf:budget
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: bundle-budget
path: test-results/performance/bundle.json
retention-days: 30
+75
View File
@@ -0,0 +1,75 @@
name: Dashboard image
on:
pull_request:
push:
tags: ['v*']
permissions:
contents: read
env:
IMAGE: ghcr.io/qrun-io/qqq-frontend-next
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
if: github.event_name == 'push'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
file: docker/quickstart/Dockerfile
platforms: linux/${{ matrix.arch }}
push: ${{ github.event_name == 'push' }}
load: ${{ github.event_name == 'pull_request' }}
tags: ${{ env.IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
cache-from: type=gha,scope=dashboard-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=dashboard-${{ matrix.arch }}
- name: Verify runnable dashboard
if: github.event_name == 'pull_request'
env:
TAG: ${{ env.IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
run: |
container=$(docker run -d --rm -p 127.0.0.1:3000:3000 "$TAG")
trap 'docker rm -f "$container"' EXIT
curl -fsS --max-time 3 --retry 30 --retry-delay 1 --retry-all-errors \
--retry-max-time 90 http://127.0.0.1:3000/app/person >/dev/null \
|| { docker logs "$container"; exit 1; }
publish:
if: github.event_name == 'push'
needs: build
runs-on: ubuntu-24.04
permissions:
packages: write
steps:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish versioned multi-architecture image
run: |
docker buildx imagetools create \
--tag "$IMAGE:${GITHUB_REF_NAME#v}" \
"$IMAGE:$GITHUB_SHA-amd64" "$IMAGE:$GITHUB_SHA-arm64"
-3
View File
@@ -1,3 +0,0 @@
profile: frontend-next
orb_version: '0.8'
default_branch: main
@@ -238,7 +238,7 @@ function mockAssistant(options: { config?: Record<string, unknown>; list?: Assis
*/
async function openRail(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
await user.click(await screen.findByRole('button', { name: 'Show conversations' }))
await user.click(await screen.findByRole('button', { name: 'Conversation history' }))
await screen.findByRole('button', { name: UNREAD_ROW })
}
@@ -347,7 +347,7 @@ describe('conversation list in the panel', () => {
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
const toggle = await screen.findByRole('button', { name: 'Show conversations' })
const toggle = await screen.findByRole('button', { name: 'Conversation history' })
await waitFor(() => expect(toggle.querySelector('[data-qqq-id="assistant-rail-toggle-dot"]')).not.toBeNull())
expect(toggle).toHaveAccessibleDescription('Other conversations are working or have unread replies')
})
@@ -357,7 +357,7 @@ describe('conversation list in the panel', () => {
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
const toggle = await screen.findByRole('button', { name: 'Show conversations' })
const toggle = await screen.findByRole('button', { name: 'Conversation history' })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(toggle.querySelector('[data-qqq-id="assistant-rail-toggle-dot"]')).toBeNull()
})
@@ -404,7 +404,7 @@ describe('unread correctness', () => {
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Ask the assistant/ }))
await user.click(await screen.findByRole('button', { name: 'Show conversations' }))
await user.click(await screen.findByRole('button', { name: 'Conversation history' }))
await user.click(await screen.findByRole('button', { name: 'Quiet one' }))
const composer = await screen.findByRole('textbox', { name: 'Message the assistant' })
await user.type(composer, 'Hello{Enter}')
@@ -420,7 +420,7 @@ describe('unread correctness', () => {
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Ask the assistant, 2 unread/ }))
await user.click(await screen.findByRole('button', { name: 'Show conversations' }))
await user.click(await screen.findByRole('button', { name: 'Conversation history' }))
await user.click(await screen.findByRole('button', { name: UNREAD_ROW }))
await waitFor(() => expect(traffic.patches).toContainEqual({ id: '2', body: { read: true } }))
})
@@ -0,0 +1,49 @@
/*
* 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 AssistantHeaderTip — hover and keyboard-focus tooltip for the assistant panel's icon buttons.
*/
'use client'
import React from 'react'
import * as Tooltip from '@radix-ui/react-tooltip'
/**
* Wraps one icon button so its label shows as a tooltip on hover and on keyboard focus. The button
* keeps its own `aria-label`; the tooltip is a visual duplicate for sighted users.
*
* @param props - Component props.
* @param props.label - Tooltip text.
* @param props.children - The single button element.
* @returns The button with a tooltip.
*/
export function HeaderTip({ label, children }: { label: string; children: React.ReactElement }) {
return (
<Tooltip.Provider delayDuration={300}>
<Tooltip.Root>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content sideOffset={4} className="z-[var(--qqq-z-tooltip,3000)] rounded bg-popover px-2 py-1 text-xs text-popover-foreground shadow" data-qqq-id="assistant-tooltip">
{label}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
)
}
@@ -650,7 +650,7 @@ describe('answer lifecycle and recovery', () => {
const user = userEvent.setup()
renderShell()
await openComposer(user)
await user.click(screen.getByRole('button', { name: 'Show conversations' }))
await user.click(screen.getByRole('button', { name: 'Conversation history' }))
await user.click(await screen.findByRole('button', { name: 'Saved conversation' }))
await user.click(await screen.findByRole('button', { name: 'Stop the reply' }))
expect(await screen.findByText('Finished on another pod', { selector: MESSAGE_TEXT })).toBeInTheDocument()
@@ -755,3 +755,72 @@ describe('multiple run controls', () => {
await waitFor(() => expect(screen.queryByText('Writing...')).not.toBeInTheDocument())
})
})
describe('panel header chrome and starter prompts', () => {
const LONG = 'Which of my open orders have been waiting on a customer reply for more than two weeks, and what was the last thing we said to them?'
it('labels the history, new and close buttons with an icon, aria-label and keyboard-focus tooltip', async () => {
mockAssistant()
const user = userEvent.setup()
renderShell()
await openComposer(user)
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
const history = within(panel).getByRole('button', { name: 'Conversation history' })
expect(history).toHaveAttribute('aria-expanded', 'false')
expect(history.querySelector('svg')).toHaveClass('lucide-history')
expect(within(panel).getByRole('button', { name: 'New conversation' })).toBeInTheDocument()
expect(within(panel).getByRole('button', { name: 'Close assistant' })).toBeInTheDocument()
// keyboard focus alone (no hover) opens the tooltip
act(() => history.focus())
expect(await screen.findByRole('tooltip', {}, { timeout: 3000 })).toHaveTextContent('Conversation history')
await user.click(history)
expect(within(panel).getByRole('button', { name: 'Hide conversation history' })).toHaveAttribute('aria-expanded', 'true')
})
it('wraps long starter prompts inside the chip and stacks them, with no truncation', async () => {
mockAssistant({ enabled: true, profile: { starterPrompts: [LONG, 'Short one', LONG + ' Again?'] } })
const user = userEvent.setup()
renderShell()
await openComposer(user)
const list = await screen.findByRole('list', { name: 'Suggested questions' })
const chips = within(list).getAllByRole('button')
expect(chips).toHaveLength(3)
for (const chip of chips) {
expect(chip).toHaveClass('whitespace-normal', 'break-words', 'w-full')
expect(chip.className).not.toMatch(/truncate|whitespace-nowrap|overflow-hidden/)
expect(chip.parentElement).toHaveClass('w-full', 'min-w-0', '@md:w-auto')
}
expect(chips[0]).toHaveTextContent(LONG)
})
it.each([[0], [1], [2], [3]])('renders %i starter prompts without an empty list', async (count) => {
mockAssistant({ enabled: true, profile: { starterPrompts: Array.from({ length: count }, (_, index) => `Question ${index + 1}`) } })
const user = userEvent.setup()
renderShell()
await openComposer(user)
await screen.findByText('Ask about your data')
const list = screen.queryByRole('list', { name: 'Suggested questions' })
if (count === 0) expect(list).not.toBeInTheDocument()
else expect(within(list as HTMLElement).getAllByRole('button')).toHaveLength(count)
})
it('has no axe violations: empty state, rail open, and with a conversation', async () => {
mockAssistant({ enabled: true, profile: { starterPrompts: [LONG, 'Short one'] } })
server.use(http.get(`${BASE}/conversations`, () => HttpResponse.json({ conversations: [{ ...CONVERSATION, title: 'Saved conversation' }] })))
const user = userEvent.setup()
renderShell()
await openComposer(user)
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
expect(await axe(panel)).toHaveNoViolations()
await user.click(within(panel).getByRole('button', { name: 'Conversation history' }))
await screen.findByRole('button', { name: 'Saved conversation' })
expect(await axe(panel)).toHaveNoViolations()
persisted.messages = storedTurn('Find records', [{ type: 'text', text: 'Here they are.' }])
await user.click(screen.getByRole('button', { name: 'Saved conversation' }))
expect(await screen.findByText('Here they are.', { selector: MESSAGE_TEXT })).toBeInTheDocument()
expect(await axe(panel)).toHaveNoViolations()
})
})
+15 -8
View File
@@ -28,7 +28,7 @@ import * as AlertDialog from '@radix-ui/react-alert-dialog'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { usePathname } from 'next/navigation'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Archive, ArchiveRestore, Check, Eye, MoreHorizontal, PanelLeft, Pencil, PencilLine, Pin, PinOff, SquarePen, Trash2, X } from 'lucide-react'
import { Archive, ArchiveRestore, Check, Eye, History, MoreHorizontal, Pencil, PencilLine, Pin, PinOff, SquarePen, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import type { QInstance } from '@/types'
@@ -48,6 +48,7 @@ import { cn } from '@/lib/utils/cn'
import { ASSISTANT_DOCKED_MEDIA_QUERY, assistantKeys, useAssistant, type AssistantController } from './assistant-context'
import { AssistantElapsed } from './AssistantRunStatus'
import { AssistantComposer } from './AssistantComposer'
import { HeaderTip } from './AssistantHeaderTip'
import { AssistantMessageView } from './AssistantMessageView'
import { ConversationRail, conversationTitle } from './ConversationRail'
import { markConversationRead } from './mark-read'
@@ -315,17 +316,18 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
return (
<div className="flex h-full min-h-0 min-w-0 flex-col">
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2" style={{ minHeight: 'var(--qqq-header-height)' }}>
<HeaderTip label={railOpen ? 'Hide conversation history' : 'Conversation history'}>
<button
type="button"
className={cn(iconButton, 'relative')}
onClick={() => setRailOpen((value) => !value)}
aria-expanded={railOpen}
aria-controls={listId}
aria-label={railOpen ? 'Hide conversations' : 'Show conversations'}
aria-label={railOpen ? 'Hide conversation history' : 'Conversation history'}
aria-describedby={othersBusy ? `${listId}-busy` : undefined}
data-qqq-id="assistant-rail-toggle"
>
<PanelLeft className="h-4 w-4" aria-hidden="true" />
<History className="h-4 w-4" aria-hidden="true" />
{othersBusy && (
<>
<span className="absolute right-0.5 top-0.5 h-2 w-2 rounded-full bg-primary" aria-hidden="true" data-qqq-id="assistant-rail-toggle-dot" />
@@ -333,6 +335,7 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
</>
)}
</button>
</HeaderTip>
{renaming && conversation ? (
<form
className="min-w-0 flex-1"
@@ -420,6 +423,7 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
<HeaderTip label="New conversation">
<button
type="button"
className={iconButton}
@@ -434,9 +438,12 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
>
<SquarePen className="h-4 w-4" aria-hidden="true" />
</button>
<button type="button" className={iconButton} onClick={() => assistant.setOpen(false)} aria-label="Close assistant" data-qqq-id="assistant-close">
<X className="h-4 w-4" aria-hidden="true" />
</button>
</HeaderTip>
<HeaderTip label="Close assistant">
<button type="button" className={iconButton} onClick={() => assistant.setOpen(false)} aria-label="Close assistant" data-qqq-id="assistant-close">
<X className="h-4 w-4" aria-hidden="true" />
</button>
</HeaderTip>
</div>
{Object.entries(engine.runs).filter(([id]) => (Number(id) !== activeConversationId || !showMessages)).map(([id, runs]) => {
@@ -495,11 +502,11 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
{(profile?.starterPrompts?.length ?? 0) > 0 && (
<ul className="mt-4 flex flex-wrap justify-center gap-2" aria-label="Suggested questions" data-qqq-id="assistant-starters">
{profile?.starterPrompts?.map((prompt) => (
<li key={prompt} className="max-w-full">
<li key={prompt} className="w-full min-w-0 @md:w-auto @md:max-w-full" data-qqq-id="assistant-starter">
<button
type="button"
onClick={() => send(prompt)}
className="max-w-full rounded-full border border-border bg-background px-3 py-1.5 text-left text-xs text-foreground hover:bg-accent pointer-coarse:min-h-11 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
className="w-full whitespace-normal break-words rounded-2xl border border-border bg-background px-3 py-1.5 text-left text-xs text-foreground hover:bg-accent pointer-coarse:min-h-11 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
>
{prompt}
</button>
@@ -136,12 +136,10 @@ test.describe('command palette and search', () => {
await page.locator('[data-qqq-id="button-search"]').click()
const dialog = page.getByRole('dialog', { name: 'Command palette' })
await dialog.getByRole('combobox').fill('clone')
// Help arrives asynchronously and can share the process label; it is not a local page match.
await expect(dialog.locator('[data-qqq-id="help-result-process:clonePeople;slot:help"]')).toBeVisible()
const clone = dialog.locator('[data-qqq-id="command-item-clonePeople"]')
const clone = dialog.getByRole('option', { name: /Clone People/ })
await expect(clone).toHaveCount(1)
await expect(clone).toContainText('People App')
await expect(dialog.locator('[data-qqq-id^="command-item-"]')).toHaveCount(1)
await expect(dialog.getByRole('option')).toHaveCount(2)
await expect(dialog.locator('[data-qqq-id="command-search-all"]')).toContainText('Search everything for “clone”')
await expectTouchTargets(dialog)
await expect(clone).toHaveAttribute('aria-selected', 'true')
@@ -9,7 +9,7 @@
import type { Page } from '@playwright/test'
import { expect, open, test } from '../../support/fixtures'
import { expectTouchReady } from '../../support/touch'
import { expectLoaded, widget, widgetBody } from './widget-support'
import { expectLoaded, isLargeLayout, widget, widgetBody } from './widget-support'
/** Records every widget data request the page makes. */
function widgetRequests(page: Page): string[] {
@@ -37,14 +37,13 @@ test.describe('as administrator', () => {
await expect(parent.locator('[data-qqq-id="widget-label-accChildA"]')).toHaveText('Child A')
const a = await parent.locator('[data-qqq-id="widget-accChildA"]').boundingBox()
const b = await parent.locator('[data-qqq-id="widget-accChildB"]').boundingBox()
const full = await parent.boundingBox()
const rem = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).fontSize))
if (full!.width >= 40 * rem) {
if (await isLargeLayout(page)) {
// children sit side by side (gridColumns 6 of 12)
expect(Math.round(a!.y)).toBe(Math.round(b!.y))
expect(b!.x).toBeGreaterThan(a!.x)
} else {
// Below the two-column container breakpoint each child spans the parent, stacked.
// below the large breakpoint the grid is one column: each child spans the parent, stacked
const full = await parent.boundingBox()
expect(Math.round(a!.width)).toBe(Math.round(full!.width))
expect(Math.round(b!.width)).toBe(Math.round(full!.width))
expect(Math.round(b!.x)).toBe(Math.round(a!.x))
@@ -9,7 +9,7 @@
import { readChartLabelBoxes, type ChartLabelBox } from '../../support/chart-label-geometry'
import { expect, open, test } from '../../support/fixtures'
import { expectNoHorizontalScroll, expectTouchReady } from '../../support/touch'
import { chartTable, expectLoaded, widget, widgetPayload } from './widget-support'
import { chartTable, expectLoaded, isLargeLayout, widget, widgetPayload } from './widget-support'
const DASHBOARD = '/app/SampleWidgetsDashboard'
@@ -33,15 +33,10 @@ test.describe('sample widgets dashboard', () => {
const third = await page.locator('[data-qqq-id="widget-grid-item-SampleMultiStatisticsWidget"]').boundingBox()
// gridColumns 12 spans the grid
expect(Math.round(composite!.width)).toBe(Math.round(full!.width))
const rem = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).fontSize))
if (full!.width >= 60 * rem) {
if (await isLargeLayout(page)) {
// gridColumns 4 takes a third of it (less the gaps)
expect(third!.width / full!.width).toBeGreaterThan(0.3)
expect(third!.width / full!.width).toBeLessThan(0.34)
} else if (full!.width >= 40 * rem) {
// Tablet-sized containers use two columns, preserving room for each widget.
expect(third!.width / full!.width).toBeGreaterThan(0.48)
expect(third!.width / full!.width).toBeLessThan(0.5)
} else {
// on phones and tablets every widget takes the full width, one per row, in declared order
const boxes = await items.evaluateAll((nodes) => nodes.map((node) => node.getBoundingClientRect().toJSON() as DOMRect))
@@ -474,10 +474,6 @@ test('[WID-073] create a report from a query, switch tables, save, reopen and re
const stored = (await sqlRows(backend, `select table_name, columns_json from saved_report where id = ${Number(id)}`))[0]
expect(stored.table_name).toBe('audit')
expect(JSON.parse(stored.columns_json).columns.some((column: { name: string }) => ['__check__', 'code', 'shelf', 'name'].includes(column.name))).toBe(false)
// Reopen a fully loaded report; reloading during preview reads interrupts WebKit's XHRs.
await expectLoaded(page, 'reportSetupWidget')
await expect(byId(page, 'filter-preview-refresh-reportSetupWidget')).toBeEnabled()
await expect(page.locator('[data-widget-type][aria-busy="true"]').filter({ visible: true })).toHaveCount(0)
await page.reload()
await expect(byId(page, 'report-columns-reportSetupWidget').locator('li')).toHaveText(['Id'])
await recordAction(page, 'Render Report')
@@ -67,6 +67,11 @@ export async function isPhoneLayout(page: Page): Promise<boolean> {
return page.evaluate(() => matchMedia('(max-width: 767.98px)').matches)
}
/** Whether the page shows the desktop dashboard grid (Tailwind's `lg` breakpoint, 1024 px). */
export async function isLargeLayout(page: Page): Promise<boolean> {
return page.evaluate(() => matchMedia('(min-width: 1024px)').matches)
}
/**
* Opens a record view and shows every section: on a phone the sections are an accordion with
* only the first one open, so widgets in the others render once their section is expanded.
@@ -7,6 +7,7 @@
/** @file Exercises the real variant lifecycle with only process and filesystem boundaries replaced. */
import { EventEmitter } from 'node:events'
import path from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({
@@ -39,7 +40,7 @@ vi.mock('node:child_process', () => {
// Existing stale export and build files cannot determine the browser's selected server.
vi.mock('node:fs', async (original) => {
const fs = {
...await original<typeof import('node:fs')>(), existsSync: (file: string) => file.endsWith('next-dashboard/index.html') ? state.hasExport : true, mkdirSync: () => {}, rmSync: () => {},
...await original<typeof import('node:fs')>(), existsSync: (file: string) => file.endsWith(path.join('next-dashboard', 'index.html')) ? state.hasExport : true, mkdirSync: () => {}, rmSync: () => {},
readdirSync: () => ['Fixture.java'],
readFileSync: (file: string) => file.endsWith('routes-manifest.json')
? JSON.stringify({ rewrites: { beforeFiles: [], afterFiles: ['qqq', 'data', 'widget', 'metaData', 'download', 'processes', 'possibleValues', 'reports', 'manageSession', 'apis.json', 'api', 'qa'].map((prefix) => ({ source: `/${prefix}/:path*`, destination: `http://127.0.0.1:${state.wrongTarget ? '18765' : '18775'}/${prefix}/:path*` })), fallback: [] } })
+88
View File
@@ -0,0 +1,88 @@
// E2E: the assistant panel header (history / new / close buttons with tooltips) and the example
// prompts: long prompts wrap inside their chips at every width, nothing clips, nothing scrolls
// sideways. Set QQQ_ASSISTANT_SCREENSHOTS to a directory to keep a screenshot per viewport.
import { test, expect, type Page, type Route } from '@playwright/test'
import { METADATA, setupApiMocks } from './api-mocks'
import { checkA11y } from './a11y-helpers'
const VIEWPORTS = [
{ name: '390', width: 390, height: 844 },
{ name: '768', width: 768, height: 1024 },
{ name: '1024', width: 1024, height: 768 },
{ name: '1440', width: 1440, height: 900 },
{ name: '1920', width: 1920, height: 1080 },
{ name: '3200', width: 3200, height: 1800 },
]
const SCREENSHOT_DIR = process.env.QQQ_ASSISTANT_SCREENSHOTS
const CONVERSATION = { id: 4, title: 'Active people', status: 'ACTIVE', pinned: false, lastMessageAt: null, unreadCount: 0, createDate: '2026-10-12T00:00:00Z' }
const PROMPTS = [
'Which of my open orders have been waiting on a customer reply for more than two weeks, and what was the last thing we said to them?',
'Summarize last quarter by region and flag anything that moved more than ten percent',
'Show me every record I changed this week',
]
/**
* Mocks the assistant routes with three long example prompts and one saved conversation.
*
* @param page - The page.
*/
async function mockAssistant(page: Page) {
const json = (route: Route, body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
await page.route('**/qqq/v1/version', (route) => json(route, { apiVersion: '1.4.0', features: [], extensions: [{ name: 'assistant', version: '1.0.0' }] }))
await page.route(/\/qqq\/v1\/metaData(\?|$)/, (route) => json(route, { ...METADATA, supplementalInstanceMetaData: { assistant: {} } }))
await page.route('**/qqq/v1/assistant/**', (route) => {
const path = new URL(route.request().url()).pathname.replace('/qqq/v1/assistant', '')
if (path === '/config') return json(route, { enabled: true, shortcut: 'mod+j', model: 'claude-sonnet-5-5', features: { writes: false }, profile: { starterPrompts: PROMPTS } })
if (path === '/conversations') return json(route, { conversations: [CONVERSATION] })
return json(route, { error: 'not mocked' }, 404)
})
}
for (const viewport of VIEWPORTS) {
test(`assistant panel chrome and example prompts at ${viewport.name}px`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height })
await setupApiMocks(page)
await mockAssistant(page)
await page.goto('/app/person')
await page.waitForSelector('[role="status"][aria-label="Loading"]', { state: 'hidden', timeout: 30000 }).catch(() => null)
await expect(page.locator('[data-qqq-id="main-content"]')).toBeVisible({ timeout: 30000 })
if (viewport.width < 640) {
// compact header: the assistant opens from the account menu
await page.locator('[data-qqq-id="button-account"]').click()
await page.getByRole('menuitem', { name: 'Ask the assistant' }).click()
} else {
await page.locator('[data-qqq-id="button-assistant"]').click()
}
const panel = page.locator('[data-qqq-id="assistant-panel"]')
await expect(panel).toBeVisible()
const starters = panel.locator('[data-qqq-id="assistant-starters"]')
await expect(starters.getByRole('button')).toHaveCount(3)
// nothing clipped: each chip's text box fits inside the chip, and the chips fit inside the list
const clipped = await starters.evaluate((list) => {
const bad: string[] = []
const listBox = list.getBoundingClientRect()
for (const chip of Array.from(list.querySelectorAll('button'))) {
const box = chip.getBoundingClientRect()
if (chip.scrollWidth > chip.clientWidth + 1 || chip.scrollHeight > chip.clientHeight + 1) bad.push('overflow')
if (box.left < listBox.left - 1 || box.right > listBox.right + 1) bad.push('outside')
}
return bad
})
expect(clipped).toEqual([])
expect(await page.evaluate(() => document.scrollingElement!.scrollWidth <= window.innerWidth + 1)).toBe(true)
expect(await panel.locator('[data-qqq-id="assistant-messages"]').evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true)
if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/empty-${viewport.name}.png` })
// history rail open
await panel.locator('[data-qqq-id="assistant-rail-toggle"]').click()
await expect(panel.getByRole('button', { name: 'Active people', exact: true })).toBeVisible()
await panel.locator('[data-qqq-id="assistant-rail-toggle"]').focus()
await page.waitForTimeout(600)
if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/history-${viewport.name}.png` })
await checkA11y(page)
})
}
+6 -3
View File
@@ -49,10 +49,13 @@ it('keeps the variant build, static assets and entrypoint after the main build d
expect(readFileSync(path.join(staged, 'public/icon.svg'), 'utf8')).toBe('<svg/>')
expect(readFileSync(path.join(staged, 'qqq-server.mjs'), 'utf8')).toBe('container entrypoint')
expect(readFileSync(path.join(staged, 'branding-assets.mjs'), 'utf8')).toBe('declared branding aliases')
expect(readlinkSync(path.join(staged, 'node_modules/example'))).toBe('.pnpm/example')
expect(readlinkSync(path.join(staged, 'node_modules/example'))).toBe(path.join('.pnpm', 'example'))
expect(readFileSync(path.join(staged, 'node_modules/example/index.js'), 'utf8')).toBe('independent dependency')
expect(statSync(path.join(staged, 'server.js')).mode & 0o777).toBe(0o600)
expect(statSync(path.join(staged, '.next/static')).mode & 0o777).toBe(0o700)
// Windows does not implement POSIX owner/group/other permission bits.
if (process.platform !== 'win32') {
expect(statSync(path.join(staged, 'server.js')).mode & 0o777).toBe(0o600)
expect(statSync(path.join(staged, '.next/static')).mode & 0o777).toBe(0o700)
}
})
it('rejects an old shared-backend rewrite even with a new valid variant marker', () => {