Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d0f93f75 | ||
|
|
9756b9d93b | ||
|
|
f205992878 | ||
|
|
5f503fa7e9 | ||
|
|
09828ab1cc | ||
|
|
c39748b1a0 | ||
|
|
829ef6b384 | ||
|
|
a94e1e39c8 | ||
|
|
9bea8cc63f |
No files matched your search
+10
-76
@@ -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: /.*/
|
||||
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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: [] } })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user