Compare commits

..
Author SHA1 Message Date
dependabot[bot] 90d0f93f75 build(deps): Bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [QRun-IO/qqq-orb/.github/workflows/next-ci.yml](https://github.com/qrun-io/qqq-orb), [QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml](https://github.com/qrun-io/qqq-orb) and [QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml](https://github.com/qrun-io/qqq-orb).


Updates `QRun-IO/qqq-orb/.github/workflows/next-ci.yml` from 0f33ddadf88b9a1b940b8883782fe2000ee1d2bd to 1c389210e6dfda1630d053c07ae4dae523dd5576
- [Release notes](https://github.com/qrun-io/qqq-orb/releases)
- [Changelog](https://github.com/QRun-IO/qqq-orb/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/qrun-io/qqq-orb/compare/0f33ddadf88b9a1b940b8883782fe2000ee1d2bd...1c389210e6dfda1630d053c07ae4dae523dd5576)

Updates `QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml` from 0f33ddadf88b9a1b940b8883782fe2000ee1d2bd to 1c389210e6dfda1630d053c07ae4dae523dd5576
- [Release notes](https://github.com/qrun-io/qqq-orb/releases)
- [Changelog](https://github.com/QRun-IO/qqq-orb/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/qrun-io/qqq-orb/compare/0f33ddadf88b9a1b940b8883782fe2000ee1d2bd...1c389210e6dfda1630d053c07ae4dae523dd5576)

Updates `QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml` from 0f33ddadf88b9a1b940b8883782fe2000ee1d2bd to 1c389210e6dfda1630d053c07ae4dae523dd5576
- [Release notes](https://github.com/qrun-io/qqq-orb/releases)
- [Changelog](https://github.com/QRun-IO/qqq-orb/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/qrun-io/qqq-orb/compare/0f33ddadf88b9a1b940b8883782fe2000ee1d2bd...1c389210e6dfda1630d053c07ae4dae523dd5576)

---
updated-dependencies:
- dependency-name: QRun-IO/qqq-orb/.github/workflows/next-ci.yml
  dependency-version: 1c389210e6dfda1630d053c07ae4dae523dd5576
  dependency-type: direct:production
  dependency-group: github-actions
- dependency-name: QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml
  dependency-version: 1c389210e6dfda1630d053c07ae4dae523dd5576
  dependency-type: direct:production
  dependency-group: github-actions
- dependency-name: QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml
  dependency-version: 1c389210e6dfda1630d053c07ae4dae523dd5576
  dependency-type: direct:production
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-28 05:18:16 +00:00
james.maesandCodex 9756b9d93b ci: adopt managed orb 0.8 patch track (#193)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 16:20:56 -05:00
james.maesandCodex f205992878 ci: adopt shared orb 0.7.17 (#192)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:37:37 -05:00
james.maesandCodex 5f503fa7e9 test: disambiguate async help from process search results (#191)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 09:15:38 -05:00
james.maesandCodex 09828ab1cc ci: adopt central orb 0.7.12 and stable gates (#190)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 03:49:28 -05:00
james.maesandCodex c39748b1a0 test: stabilize widget layout and report reload acceptance (#189)
* test: assert widget layouts at container breakpoints

Co-Authored-By: Codex <noreply@openai.com>

* test: wait for saved report readiness before reloading

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 03:08:45 -05:00
james.maesandCodex 829ef6b384 ci: align canonical shared orb workers (#188)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:49:08 -05:00
james.maesandCodex a94e1e39c8 ci: adopt shared orb 0.7.11 (#187)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:12:27 -05:00
james.maesandCodex 9bea8cc63f ci: centralize workflows in QQQ orb (#185)
Co-authored-by: Codex <noreply@openai.com>
2026-10-20 20:04:31 -05:00
17 changed files with 87 additions and 833 deletions

No files matched your search

+10 -76
View File
@@ -1,80 +1,14 @@
version: 2.1
setup: true
orbs:
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
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
test_only:
setup:
jobs:
- 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]+)?$/
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: /.*/
tags:
only: /.*/
-281
View File
@@ -1,281 +0,0 @@
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
+36 -153
View File
@@ -1,158 +1,41 @@
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:
name: Next centralized CI
'on':
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
push:
branches: [main]
branches:
- main
tags:
- v*
workflow_dispatch:
inputs:
qqq_ref:
description: QQQ source ref for acceptance
type: string
required: false
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
NEXT_TELEMETRY_DISABLED: '1'
STORYBOOK_DISABLE_TELEMETRY: '1'
jobs:
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
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
-75
View File
@@ -1,75 +0,0 @@
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
@@ -0,0 +1,3 @@
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: 'Conversation history' }))
await user.click(await screen.findByRole('button', { name: 'Show conversations' }))
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: 'Conversation history' })
const toggle = await screen.findByRole('button', { name: 'Show conversations' })
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: 'Conversation history' })
const toggle = await screen.findByRole('button', { name: 'Show conversations' })
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: 'Conversation history' }))
await user.click(await screen.findByRole('button', { name: 'Show conversations' }))
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: 'Conversation history' }))
await user.click(await screen.findByRole('button', { name: 'Show conversations' }))
await user.click(await screen.findByRole('button', { name: UNREAD_ROW }))
await waitFor(() => expect(traffic.patches).toContainEqual({ id: '2', body: { read: true } }))
})
@@ -1,49 +0,0 @@
/*
* 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: 'Conversation history' }))
await user.click(screen.getByRole('button', { name: 'Show conversations' }))
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,72 +755,3 @@ 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()
})
})
+8 -15
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, History, MoreHorizontal, Pencil, PencilLine, Pin, PinOff, SquarePen, Trash2, X } from 'lucide-react'
import { Archive, ArchiveRestore, Check, Eye, MoreHorizontal, PanelLeft, Pencil, PencilLine, Pin, PinOff, SquarePen, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import type { QInstance } from '@/types'
@@ -48,7 +48,6 @@ 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'
@@ -316,18 +315,17 @@ 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 conversation history' : 'Conversation history'}
aria-label={railOpen ? 'Hide conversations' : 'Show conversations'}
aria-describedby={othersBusy ? `${listId}-busy` : undefined}
data-qqq-id="assistant-rail-toggle"
>
<History className="h-4 w-4" aria-hidden="true" />
<PanelLeft 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" />
@@ -335,7 +333,6 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
</>
)}
</button>
</HeaderTip>
{renaming && conversation ? (
<form
className="min-w-0 flex-1"
@@ -423,7 +420,6 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
<HeaderTip label="New conversation">
<button
type="button"
className={iconButton}
@@ -438,12 +434,9 @@ function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyP
>
<SquarePen 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>
<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>
</div>
{Object.entries(engine.runs).filter(([id]) => (Number(id) !== activeConversationId || !showMessages)).map(([id, runs]) => {
@@ -502,11 +495,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="w-full min-w-0 @md:w-auto @md:max-w-full" data-qqq-id="assistant-starter">
<li key={prompt} className="max-w-full">
<button
type="button"
onClick={() => send(prompt)}
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"
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"
>
{prompt}
</button>
@@ -136,10 +136,12 @@ 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')
const clone = dialog.getByRole('option', { name: /Clone People/ })
// 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"]')
await expect(clone).toHaveCount(1)
await expect(clone).toContainText('People App')
await expect(dialog.getByRole('option')).toHaveCount(2)
await expect(dialog.locator('[data-qqq-id^="command-item-"]')).toHaveCount(1)
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, isLargeLayout, widget, widgetBody } from './widget-support'
import { expectLoaded, widget, widgetBody } from './widget-support'
/** Records every widget data request the page makes. */
function widgetRequests(page: Page): string[] {
@@ -37,13 +37,14 @@ 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()
if (await isLargeLayout(page)) {
const full = await parent.boundingBox()
const rem = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).fontSize))
if (full!.width >= 40 * rem) {
// 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 large breakpoint the grid is one column: each child spans the parent, stacked
const full = await parent.boundingBox()
// Below the two-column container breakpoint each child spans the parent, stacked.
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, isLargeLayout, widget, widgetPayload } from './widget-support'
import { chartTable, expectLoaded, widget, widgetPayload } from './widget-support'
const DASHBOARD = '/app/SampleWidgetsDashboard'
@@ -33,10 +33,15 @@ 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))
if (await isLargeLayout(page)) {
const rem = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).fontSize))
if (full!.width >= 60 * rem) {
// 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,6 +474,10 @@ 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,11 +67,6 @@ 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,7 +7,6 @@
/** @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(() => ({
@@ -40,7 +39,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(path.join('next-dashboard', 'index.html')) ? state.hasExport : true, mkdirSync: () => {}, rmSync: () => {},
...await original<typeof import('node:fs')>(), existsSync: (file: string) => file.endsWith('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
@@ -1,88 +0,0 @@
// 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)
})
}
+3 -6
View File
@@ -49,13 +49,10 @@ 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(path.join('.pnpm', 'example'))
expect(readlinkSync(path.join(staged, 'node_modules/example'))).toBe('.pnpm/example')
expect(readFileSync(path.join(staged, 'node_modules/example/index.js'), 'utf8')).toBe('independent dependency')
// 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)
}
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', () => {