ci: balance e2e shards by test duration (#2489)

This commit is contained in:
Carlos Florêncio
2026-08-11 19:33:50 +01:00
committed by GitHub
parent e21fb8349b
commit 1429fa1a9a
49 changed files with 4331 additions and 152 deletions
+1
View File
@@ -41,6 +41,7 @@ RUN apt-get update \
make \
jq \
tar \
unzip \
xz-utils \
&& rm -rf /var/lib/apt/lists/*
+3 -1
View File
@@ -340,7 +340,9 @@ jobs:
test-windows:
name: Backend (windows)
runs-on: windows-latest
timeout-minutes: 15
# The race-sensitive Windows package suite can exceed 15 minutes on a
# busy hosted runner; keep the cap finite while allowing normal completion.
timeout-minutes: 20
# Least-privilege: this job only reads source and runs tests, no PR/issue
# writes or release operations. Flagged by CodeQL workflow audit + matching
# CodeRabbit nitpick.
+297 -11
View File
@@ -26,6 +26,12 @@ on:
# Manual trigger for verifying CI on draft PRs and ad-hoc reruns. The
# default sharded run uses the same ref the user picks in the dispatch UI.
workflow_dispatch:
inputs:
fail_on_flaky:
description: "Fail the run when Playwright needs a retry"
required: false
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -33,6 +39,8 @@ concurrency:
permissions:
contents: read
# The build/report jobs read the latest successful main timing artifact.
actions: read
# Required so the containerized build / e2e shards / e2e-report jobs can
# pull the kandev-ci image from GHCR via GITHUB_TOKEN.
packages: read
@@ -92,6 +100,98 @@ jobs:
- name: Build Docker E2E helper binaries
run: make build-backend-linux-helpers
- name: Locate latest successful main timing profile
id: main-timing-run
env:
GH_TOKEN: ${{ github.token }}
GITHUB_API_URL: ${{ github.api_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
node <<'NODE'
const fs = require("node:fs");
(async () => {
const url =
process.env.GITHUB_API_URL +
"/repos/" +
process.env.GITHUB_REPOSITORY +
"/actions/workflows/e2e-tests.yml/runs?branch=main&status=success&per_page=20";
const response = await fetch(url, {
headers: {
accept: "application/vnd.github+json",
authorization: "Bearer " + process.env.GH_TOKEN,
},
});
if (!response.ok) {
console.log(
"::warning::Unable to query successful main E2E runs (" +
response.status +
"); using count fallback.",
);
fs.appendFileSync(process.env.GITHUB_OUTPUT, "run_id=\n");
return;
}
const payload = await response.json();
const run = (payload.workflow_runs ?? []).find(
(candidate) => candidate.head_branch === "main" && candidate.conclusion === "success",
);
fs.appendFileSync(process.env.GITHUB_OUTPUT, "run_id=" + (run?.id ?? "") + "\n");
console.log(
run
? "Using successful main E2E run " + run.id + " for timing history."
: "No successful main E2E run found; using count fallback.",
);
})().catch((error) => {
console.error(error);
fs.appendFileSync(process.env.GITHUB_OUTPUT, "run_id=\n");
});
NODE
- name: Download latest main timing profile
if: steps.main-timing-run.outputs.run_id != ''
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: e2e-timing-profile
path: e2e/previous-profile
repository: ${{ github.repository }}
run-id: ${{ steps.main-timing-run.outputs.run_id }}
github-token: ${{ github.token }}
- name: Upload selected timing baseline for report job
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-selected-main-timing-profile
path: e2e/previous-profile/timing-profile.json
if-no-files-found: ignore
retention-days: 3
- name: Generate duration-aware E2E shard manifests
working-directory: apps/web
run: |
set -euo pipefail
rm -rf "$GITHUB_WORKSPACE/e2e-manifests"
if [[ -f "$GITHUB_WORKSPACE/e2e/previous-profile/timing-profile.json" ]]; then
echo "Planning from the latest successful main timing profile."
pnpm exec tsx e2e/scripts/plan-shards.ts \
--web-root "$GITHUB_WORKSPACE/apps/web" \
--output-dir "$GITHUB_WORKSPACE/e2e-manifests" \
--profile "$GITHUB_WORKSPACE/e2e/previous-profile/timing-profile.json"
else
echo "Planning with deterministic count fallback; no usable main timing profile was downloaded."
pnpm exec tsx e2e/scripts/plan-shards.ts \
--web-root "$GITHUB_WORKSPACE/apps/web" \
--output-dir "$GITHUB_WORKSPACE/e2e-manifests"
fi
- name: Upload E2E shard manifests
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-shard-manifests
path: e2e-manifests/
retention-days: 3
- name: Upload backend build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@@ -197,14 +297,26 @@ jobs:
working-directory: apps/web
run: tar -xf web-dist.tar && rm web-dist.tar
- name: Enable strict flaky-test diagnostics
if: github.event_name == 'workflow_dispatch' && inputs.fail_on_flaky
run: echo "E2E_FAIL_ON_FLAKY=1" >> "$GITHUB_ENV"
- name: Download E2E shard manifests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: e2e-shard-manifests
path: e2e-manifests
# Pinned to chromium + mobile-chrome so the dedicated `e2e-containers`
# job below owns the real-Docker project (which covers both the Docker
# executor and the SSH executor's sshd target). Without --project the
# container-bound specs would run here too, fail seeding (Docker not
# enabled for these shards), and spam every shard.
- name: Run E2E tests (shard ${{ matrix.shard }}/14)
- name: Run planned E2E tests (shard ${{ matrix.shard }}/14)
working-directory: apps/web
run: npx playwright test --config e2e/playwright.config.ts --project=chromium --project=mobile-chrome --shard=${{ matrix.shard }}/14
env:
E2E_SHARD: ${{ matrix.shard }}
run: bash e2e/scripts/run-planned-shard.sh "$GITHUB_WORKSPACE/e2e-manifests/normal/${{ matrix.shard }}.json"
- name: Upload blob report
if: always()
@@ -225,9 +337,8 @@ jobs:
# Real-Docker E2E: exercises the local_docker executor end-to-end.
# ubuntu-latest ships with Docker preinstalled, so no service container is
# required. Container-bound tests are slow (~2 min each), so we shard across
# 6 runners — Playwright's --shard splits the project's tests into N groups.
# Cleans up any leftover kandev-managed containers at the end so a leaky
# process doesn't poison the next run on the same self-hosted runner.
# 6 runners — the build job creates a duration-aware manifest for this
# cohort, and each runner executes its explicit file selection.
#
# This job runs the `containers` Playwright project, which covers both the
# Docker executor and the SSH executor (since the SSH executor's e2e tests
@@ -366,15 +477,22 @@ jobs:
working-directory: apps/web
run: tar -xf web-dist.tar && rm web-dist.tar
- name: Run container-backed E2E tests shard ${{ matrix.shard }}/6 (Docker executor + SSH executor)
- name: Enable strict flaky-test diagnostics
if: github.event_name == 'workflow_dispatch' && inputs.fail_on_flaky
run: echo "E2E_FAIL_ON_FLAKY=1" >> "$GITHUB_ENV"
- name: Download E2E shard manifests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: e2e-shard-manifests
path: e2e-manifests
- name: Run planned container-backed E2E tests shard ${{ matrix.shard }}/6 (Docker executor + SSH executor)
working-directory: apps/web
env:
KANDEV_E2E_CONTAINERS: "1"
run: npx playwright test --config e2e/playwright.config.ts --project=containers --shard=${{ matrix.shard }}/6
- name: Cleanup leftover kandev containers
if: always()
run: docker ps -aq --filter label=kandev.managed=true | xargs -r docker rm -f
E2E_SHARD: ${{ matrix.shard }}
run: bash e2e/scripts/run-planned-shard.sh "$GITHUB_WORKSPACE/e2e-manifests/containers/${{ matrix.shard }}.json"
# Named with the same `blob-report-*` prefix the merge job globs for
# so the containers results land in the unified HTML report alongside
@@ -487,6 +605,16 @@ jobs:
working-directory: apps
run: pnpm install --frozen-lockfile
# The published runtime image may predate the report-tool dependency;
# keep older images usable while the image rebuild adds unzip permanently.
- name: Ensure timing report tools are installed
run: |
if ! command -v unzip >/dev/null 2>&1; then
apt-get update
apt-get install -y --no-install-recommends unzip
rm -rf /var/lib/apt/lists/*
fi
- name: Download blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
@@ -494,7 +622,165 @@ jobs:
pattern: blob-report-*
merge-multiple: true
- name: Download E2E shard manifests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
name: e2e-shard-manifests
path: e2e-manifests
- name: Download selected main timing profile
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
name: e2e-selected-main-timing-profile
path: e2e/previous-profile
- name: Collect timing and retry diagnostics
if: always()
working-directory: apps/web
run: |
set -euo pipefail
if [[ -f "$GITHUB_WORKSPACE/e2e/previous-profile/timing-profile.json" ]]; then
pnpm exec tsx e2e/scripts/e2e-timings.ts \
--input e2e/blob-reports \
--output e2e/timing-profile.json \
--web-root "$GITHUB_WORKSPACE/apps/web" \
--branch "$GITHUB_REF_NAME" \
--sha "$GITHUB_SHA" \
--run-id "$GITHUB_RUN_ID" \
--previous "$GITHUB_WORKSPACE/e2e/previous-profile/timing-profile.json"
else
pnpm exec tsx e2e/scripts/e2e-timings.ts \
--input e2e/blob-reports \
--output e2e/timing-profile.json \
--web-root "$GITHUB_WORKSPACE/apps/web" \
--branch "$GITHUB_REF_NAME" \
--sha "$GITHUB_SHA" \
--run-id "$GITHUB_RUN_ID"
fi
pnpm exec tsx e2e/scripts/retry-summary.ts \
--input e2e/blob-reports \
--output e2e/retry-summary.json \
--manifest-dir "$GITHUB_WORKSPACE/e2e-manifests" \
--attachment-output e2e/retry-artifacts \
--artifact-name e2e-timing-diagnostics \
--artifact-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID#artifacts"
- name: Write E2E timing summary
if: always()
working-directory: apps/web
run: |
node <<'NODE'
const fs = require("node:fs");
const summaryPath = "e2e/retry-summary.json";
if (!fs.existsSync(summaryPath)) process.exit(0);
const summary = JSON.parse(fs.readFileSync(summaryPath, "utf8"));
const counts = summary.counts;
const diagnosticTests = summary.tests.filter(
(test) => test.attempts > 1 || !["passed", "skipped"].includes(test.finalStatus),
);
const markdown = (value) => String(value).replaceAll("|", "\\|").replace(/\s+/g, " ");
const lines = [
"## E2E timing and retry summary",
"",
"- Plan mode: " + summary.planning.mode,
"- Unknown units: " + summary.planning.unknownUnits,
"- Warm units: " + summary.planning.warmUnits,
"- Stale profile units: " + summary.planning.staleUnits,
"- Passed after retry: " + counts.passedAfterRetry,
"- Final failures: " + (counts.failed + counts.timedOut),
"",
"| Cohort | Shard | Predicted (s) | Actual (s) | Delta (s) | Exit |",
"| --- | ---: | ---: | ---: | ---: | ---: |",
...summary.shards.map(
(shard) =>
"| " +
shard.cohort +
" | " +
shard.shard +
"/" +
shard.shardCount +
" | " +
shard.predictedSeconds.toFixed(1) +
" | " +
shard.actualSeconds.toFixed(1) +
" | " +
shard.deltaSeconds.toFixed(1) +
" | " +
shard.exitCode +
" |",
),
];
if (diagnosticTests.length > 0) {
lines.push(
"",
"### Retry and failure details",
"",
"| Test | Attempts | Final status | Error | Artifacts |",
"| --- | ---: | --- | --- | --- |",
...diagnosticTests.map((test) => {
const links = test.attachments
.map((attachment) =>
attachment.artifactUrl
? "[" + markdown(attachment.name + " — " + attachment.path) + "](" +
attachment.artifactUrl +
")"
: markdown(attachment.name),
)
.join(", ");
return (
"| `" +
markdown(test.key) +
"` | " +
test.attempts +
" | " +
test.finalStatus +
" | " +
test.errorCategory +
" | " +
(links || "Not captured") +
" |"
);
}),
);
}
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, lines.join("\n") + "\n");
NODE
- name: Upload timing profile candidate
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-timing-profile
path: apps/web/e2e/timing-profile.json
if-no-files-found: ignore
retention-days: 30
- name: Upload retry and shard timing summary
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-retry-summary
path: apps/web/e2e/retry-summary.json
if-no-files-found: ignore
retention-days: 14
- name: Upload timing diagnostics bundle
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-timing-diagnostics
path: |
apps/web/e2e/timing-profile.json
apps/web/e2e/retry-summary.json
apps/web/e2e/retry-artifacts/
apps/web/e2e/blob-reports/*metadata.json
if-no-files-found: ignore
retention-days: 14
- name: Merge reports
if: hashFiles('apps/web/e2e/blob-reports/**/*.zip') != ''
working-directory: apps/web
# `-c e2e/playwright.config.ts` is required because the blobs come from
# two different absolute checkout paths: the containerized `e2e` shards
@@ -27,6 +27,8 @@ const (
dockerAgentctlInstancePortBase = 41001
dockerAgentctlInstancePortMax = 41100
boolStringTrue = "true"
e2eDockerScopeEnv = "KANDEV_E2E_DOCKER_SCOPE"
e2eDockerScopeLabel = "kandev.e2e.run"
gitHubCredentialHelperConfigKey = "credential.https://github.com.helper"
remoteAgentctlExecutablePath = "/usr/local/bin/agentctl"
)
@@ -545,6 +547,9 @@ exec /usr/local/bin/agentctl`,
},
AutoRemove: false, // We manage cleanup ourselves
}
if scope := os.Getenv(e2eDockerScopeEnv); scope != "" {
containerCfg.Labels[e2eDockerScopeLabel] = scope
}
if config.ExecutorProfileID != "" {
containerCfg.Labels["kandev.executor_profile_id"] = config.ExecutorProfileID
@@ -726,9 +731,11 @@ func generateBootstrapNonce() (string, error) {
// ListManagedContainers returns all containers managed by kandev
func (cm *ContainerManager) ListManagedContainers(ctx context.Context) ([]docker.ContainerInfo, error) {
return cm.dockerClient.ListContainers(ctx, map[string]string{
"kandev.managed": boolStringTrue,
})
labels := map[string]string{"kandev.managed": boolStringTrue}
if scope := os.Getenv(e2eDockerScopeEnv); scope != "" {
labels[e2eDockerScopeLabel] = scope
}
return cm.dockerClient.ListContainers(ctx, labels)
}
// GetContainerInfo returns information about a specific container
@@ -220,6 +220,21 @@ func TestBuildContainerConfig_LabelsExecutorProfileAndTaskEnvironment(t *testing
assertLabel(t, got.Labels, "com.kandev.image", "kandev/agent:custom")
}
func TestBuildContainerConfig_LabelsE2EDockerScope(t *testing.T) {
t.Setenv("KANDEV_E2E_DOCKER_SCOPE", "e2e-test-scope")
cm := newCMTest(t)
got, err := cm.buildContainerConfig(ContainerConfig{
AgentConfig: newConfigStubAgent(),
InstanceID: "0123456789abcdef",
TaskID: "task-1",
})
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
assertLabel(t, got.Labels, "kandev.e2e.run", "e2e-test-scope")
}
func TestBuildContainerConfig_PublishesAgentctlPorts(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
@@ -3,6 +3,7 @@ package backendapp
import (
"context"
"errors"
"os"
"time"
agentdocker "github.com/kandev/kandev/internal/agent/docker"
@@ -10,6 +11,11 @@ import (
"github.com/kandev/kandev/internal/system/storage/dockerstore"
)
const (
e2eDockerScopeEnv = "KANDEV_E2E_DOCKER_SCOPE"
e2eDockerScopeLabel = "kandev.e2e.run"
)
type lazyStorageDocker struct {
provider func() *agentdocker.Client
activity *activity.Coordinator
@@ -43,7 +49,7 @@ func (d *lazyStorageDocker) ListContainers(
if err != nil {
return nil, err
}
return client.ListContainers(ctx, labels)
return client.ListContainers(ctx, scopedDockerLabels(labels))
}
func (d *lazyStorageDocker) RemoveContainer(ctx context.Context, id string, force bool) error {
@@ -59,7 +65,39 @@ func (d *lazyStorageDocker) DiskUsage(ctx context.Context) (agentdocker.DiskUsag
if err != nil {
return agentdocker.DiskUsage{}, err
}
return client.DiskUsage(ctx)
usage, err := client.DiskUsage(ctx)
if err != nil {
return agentdocker.DiskUsage{}, err
}
return scopeDockerUsage(usage), nil
}
func scopedDockerLabels(labels map[string]string) map[string]string {
scope := os.Getenv(e2eDockerScopeEnv)
if scope == "" {
return labels
}
scoped := make(map[string]string, len(labels)+1)
for key, value := range labels {
scoped[key] = value
}
scoped[e2eDockerScopeLabel] = scope
return scoped
}
func scopeDockerUsage(usage agentdocker.DiskUsage) agentdocker.DiskUsage {
scope := os.Getenv(e2eDockerScopeEnv)
if scope == "" {
return usage
}
containers := make([]agentdocker.ContainerUsage, 0, len(usage.Containers))
for _, container := range usage.Containers {
if container.Labels[e2eDockerScopeLabel] == scope {
containers = append(containers, container)
}
}
usage.Containers = containers
return usage
}
func (d *lazyStorageDocker) PruneBuildCache(
@@ -0,0 +1,45 @@
package backendapp
import (
"testing"
agentdocker "github.com/kandev/kandev/internal/agent/docker"
)
func TestScopedDockerLabelsAddsOnlyTheE2EScope(t *testing.T) {
t.Setenv("KANDEV_E2E_DOCKER_SCOPE", "e2e-test-scope")
input := map[string]string{"kandev.managed": "true"}
got := scopedDockerLabels(input)
if got["kandev.e2e.run"] != "e2e-test-scope" {
t.Fatalf("scope label = %q, want e2e-test-scope", got["kandev.e2e.run"])
}
if got["kandev.managed"] != "true" {
t.Fatalf("managed label = %q, want true", got["kandev.managed"])
}
if _, ok := input["kandev.e2e.run"]; ok {
t.Fatal("scoping must not mutate the caller's label map")
}
}
func TestScopeDockerUsageFiltersOnlyContainerRecords(t *testing.T) {
t.Setenv("KANDEV_E2E_DOCKER_SCOPE", "e2e-test-scope")
usage := agentdocker.DiskUsage{
ImageLayerBytes: 10,
Images: []agentdocker.ImageUsage{{ID: "image"}},
Containers: []agentdocker.ContainerUsage{
{ID: "owned", Labels: map[string]string{"kandev.e2e.run": "e2e-test-scope"}},
{ID: "other", Labels: map[string]string{"kandev.e2e.run": "other-scope"}},
},
}
scoped := scopeDockerUsage(usage)
if len(scoped.Containers) != 1 || scoped.Containers[0].ID != "owned" {
t.Fatalf("scoped containers = %#v, want only owned record", scoped.Containers)
}
if len(scoped.Images) != 1 || scoped.ImageLayerBytes != 10 {
t.Fatalf("image usage changed while scoping containers: %#v", scoped)
}
}
+28 -21
View File
@@ -22,27 +22,34 @@ function DropdownMenuTrigger({
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
type DropdownMenuContentProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Content
> & {
// Radix forwards this Menu.Content focus event at runtime, but its dropdown
// facade omits the prop from the public declaration.
onEntryFocus?: (event: Event) => void;
onOpenAutoFocus?: (event: Event) => void;
};
const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContentProps>(
function DropdownMenuContent({ className, align = "start", sideOffset = 4, ...props }, ref) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
},
);
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "@/lib/routing/client-router";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu";
@@ -72,6 +72,29 @@ function useMenuOpenState(controlledOpen?: boolean, onOpenChange?: (open: boolea
return { open: isControlled ? controlledOpen : uncontrolledOpen, setOpen };
}
function useControlledMenuFocus(controlledOpen: boolean | undefined) {
const contentRef = useRef<HTMLDivElement>(null);
const handleOpenAutoFocus = useCallback(
(event: Event) => {
if (controlledOpen === undefined) return;
event.preventDefault();
contentRef.current?.focus({ preventScroll: true });
},
[controlledOpen],
);
const handleEntryFocus = useCallback(
(event: Event) => {
if (controlledOpen !== undefined) event.preventDefault();
},
[controlledOpen],
);
return {
ref: contentRef,
onOpenAutoFocus: handleOpenAutoFocus,
onEntryFocus: handleEntryFocus,
};
}
function rememberSelectedWorkspace(workspace: WorkspaceItem) {
if (workspaceType(workspace) === "office") {
rememberLastOfficeWorkspace(workspace);
@@ -103,6 +126,7 @@ export function AppSidebarWorkspacePicker({
const activeWorkspace = workspaces.items.find((w) => w.id === workspaces.activeId);
const activeId = activeWorkspace?.id ?? null;
const activeName = activeWorkspace?.name ?? t("sidebar:workspaceFallback");
const menuFocus = useControlledMenuFocus(controlledOpen);
const handleSelect = useCallback(
(workspace: WorkspaceItem) => {
@@ -166,7 +190,11 @@ export function AppSidebarWorkspacePicker({
className={triggerClassName}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align={contentAlign} className={cn("w-72", contentClassName)}>
<DropdownMenuContent
{...menuFocus}
align={contentAlign}
className={cn("w-72", contentClassName)}
>
<WorkspacePickerContent
workspaces={workspaces.items}
activeId={activeId}
+89 -5
View File
@@ -104,6 +104,54 @@ This project used to be named `docker`. It was renamed to `containers` once SSH
Common flags: `--shard=1/4`, `-g "fragment of test name"`, `--repeat-each=3` (flake hunting).
### Duration-aware CI sharding
CI creates an ephemeral manifest for each cohort from the current test catalog.
The normal cohort has 14 shards and runs `chromium` plus `mobile-chrome`; the
container cohort has 6 shards and runs `containers`. The manifest assigns
project/file units with a deterministic longest-processing-time planner. Matrix
jobs pass the assigned files to `run-planned-shard.sh`; they do not use ordinal
Playwright `--shard` selection.
Successful `main` runs publish `e2e-timing-profile`, which stores bounded
first-attempt passing samples keyed by project, file, and full test title. The
next planning job downloads that artifact when available. New files use the
count fallback, and changed files are marked warm and receive the conservative
multiplier. If the artifact is missing, the planner records
`profile.mode=count-fallback` and still validates complete catalog coverage.
The runner validates the manifest against the checkout before launching
Playwright. A missing, stale, overlapping, or incomplete manifest is a hard
failure; it cannot silently fall back to ordinal sharding.
To dry-run the planner locally:
```bash
cd apps
pnpm exec tsx web/e2e/scripts/plan-shards.ts \
--web-root "$PWD/web" \
--output-dir "$PWD/web/e2e/manifests"
```
To execute one generated shard locally, set `E2E_SHARD` and pass its manifest:
```bash
cd apps/web
E2E_SHARD=1 bash e2e/scripts/run-planned-shard.sh e2e/manifests/normal/1.json
```
The report job publishes `e2e-retry-summary` and `e2e-timing-diagnostics`.
The summary includes first-attempt passes, passed-after-retry tests, final
failures, timeouts, skipped tests, predicted/actual shard duration deltas, and
unknown/warm/stale/fallback planning counters. The profile candidate is uploaded on
every report, but only a successful `main` workflow run is eligible to seed a
future plan. Manifests are retained for 3 days; timing profiles for 30 days;
retry diagnostics for 14 days.
Dispatch the workflow with `fail_on_flaky=true` to set
`failOnFlakyTests: true` for a diagnostic run. Normal PR runs retain the
existing two-retry policy while the summary makes retry groups visible.
### `pnpm e2e:run` — the managed runner (build + run + teardown)
`e2e/scripts/run-e2e.sh` (aliased as `pnpm e2e:run`) handles the build, the run, and cleanup so you don't have to assemble the steps by hand. It **auto-selects docker vs host**, runs **N shards concurrently**, enforces strict WS accounting by default (`KANDEV_E2E_WS_ASSERT=1`, matching CI), and never leaves root-owned artifacts behind.
@@ -139,8 +187,18 @@ Every Playwright worker gets:
- A fresh tmpdir (`HOME`, `KANDEV_HOME_DIR`, worktree base, repo clone base — all under that tmpdir).
- A unique agentctl instance port range (`30001 + E2E_PORT_OFFSET * 1000 + workerIndex * 200`).
- Its own SQLite DB.
- A process-scoped Docker ownership label for backend-created E2E containers
and test-created SSH/storage fixtures. Cleanup and storage reporting filter
by that label and never sweep another shard's `kandev.managed=true`
containers.
Workers run in parallel across CI shards (`--shard=N/M`); within a worker, tests run serially because the `testPage` fixture calls `e2eReset` on the shared backend before each test.
Workers run in parallel across CI manifest shards; within a worker, tests run
serially because the `testPage` fixture calls `e2eReset` on the shared backend
before each test.
Docker image usage remains daemon-wide because images and their layers are
shared across shards; only container records, cleanup, and container-based
storage reporting are filtered by the process-scoped ownership label.
## Mocked vs real
@@ -161,9 +219,35 @@ The SSH executor specifically has no mock controller. Tests use a real Docker-ho
## CI
`.github/workflows/e2e-tests.yml` defines two jobs:
`.github/workflows/e2e-tests.yml` defines two test cohorts and a report job:
- `e2e` — matrixed `chromium` + `mobile-chrome` shards.
- `e2e-containers` — single job, runs `--project=containers`, needs Docker.
- `e2e` — a 14-entry matrix executing the generated normal manifests.
- `e2e-containers` — a 6-entry matrix executing the generated container
manifests and requiring Docker.
- `e2e-report` — merges blob reports and publishes timing/retry artifacts.
Both upload blob reports that `e2e-report` merges into a single HTML artifact.
The build job uploads `e2e-shard-manifests` for the current run. Both cohorts
upload blob reports that `e2e-report` merges into a single HTML artifact.
Keep the default at `workers: 1` until the controlled worker-concurrency
experiment shows a repeatable wall-time improvement without retries. Record
worker count, shard wall time, CPU/memory pressure, setup time, and retry count
for comparisons. Measure package install, runtime image startup, and browser
extraction separately from test-work balance before changing the CI matrix.
For a local worker experiment, run the same selected heavy files twice and
save the command output. The second command is diagnostic only; it does not
change the checked-in default:
```bash
cd apps/web
E2E_SHARD=2 /usr/bin/time -v bash e2e/scripts/run-planned-shard.sh \
e2e/manifests/normal/2.json -- --workers=1
E2E_SHARD=2 /usr/bin/time -v bash e2e/scripts/run-planned-shard.sh \
e2e/manifests/normal/2.json -- --workers=2
```
Record results as `{ "workers": 2, "wall_seconds": 0, "max_rss_kb": 0,
"retries": 0, "backend_errors": 0 }`. Compare at least three repetitions
with the same build, duration-aware manifest, and profile before considering a
default change.
+2
View File
@@ -4,6 +4,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { BackendFixtureEnvOverrides, createScopedEnvUse } from "./backend-env";
import { E2E_DOCKER_SCOPE } from "./docker-probe";
const BACKEND_DIR = path.resolve(__dirname, "../../../../apps/backend");
const WEB_DIR = path.resolve(__dirname, "../..");
@@ -425,6 +426,7 @@ export const backendFixture = base.extend<object, { backend: BackendContext }>({
// binaries the test runner pre-built, so containers can bind-mount them.
...(dockerEnabled
? {
KANDEV_E2E_DOCKER_SCOPE: E2E_DOCKER_SCOPE,
KANDEV_AGENTCTL_LINUX_BINARY: agentctlLinuxBinary,
KANDEV_MOCK_AGENT_LINUX_BINARY: mockAgentLinuxBinary,
}
+18 -7
View File
@@ -1,4 +1,5 @@
import { execFileSync, spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -8,6 +9,8 @@ import path from "node:path";
* Tag is stable so layer caches survive across runs.
*/
export const E2E_IMAGE_TAG = "kandev-agent:e2e";
/** Label value shared by resources created in this Playwright process. */
export const E2E_DOCKER_SCOPE = `e2e-${process.pid}-${randomUUID().slice(0, 8)}`;
const FAKE_LSP_SERVER = path.resolve(__dirname, "fake-lsp-server.mjs");
const E2E_DOCKERFILE = `FROM node:22-slim
@@ -46,14 +49,22 @@ export function buildE2EImage(): void {
}
/**
* Remove all containers labeled as managed by kandev. Called at worker
* teardown to clean up anything the suite leaked. Safe to run on a host with
* no matching containers.
* Remove only containers owned by this E2E process. Never use a daemon-wide
* kandev.managed=true sweep: another shard can share the Docker daemon.
*/
export function removeKandevContainers(): void {
const list = spawnSync("docker", ["ps", "-aq", "--filter", "label=kandev.managed=true"], {
encoding: "utf8",
});
export function removeScopedKandevContainers(scope = E2E_DOCKER_SCOPE): void {
const list = spawnSync(
"docker",
[
"ps",
"-aq",
"--filter",
"label=kandev.managed=true",
"--filter",
`label=kandev.e2e.run=${scope}`,
],
{ encoding: "utf8" },
);
if (list.status !== 0) return;
const ids = list.stdout
.split("\n")
+19 -6
View File
@@ -3,7 +3,12 @@ import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { backendFixture, type BackendContext } from "./backend";
import { buildE2EImage, E2E_IMAGE_TAG, hasDocker, removeKandevContainers } from "./docker-probe";
import {
buildE2EImage,
E2E_IMAGE_TAG,
hasDocker,
removeScopedKandevContainers,
} from "./docker-probe";
import { ApiClient } from "../helpers/api-client";
import { makeGitEnv } from "../helpers/git-helper";
import type { WorkflowStep } from "../../lib/types/http";
@@ -104,7 +109,9 @@ export const dockerTest = backendFixture.extend<
dockerExecutorProfileId: dockerProfile.id,
});
} finally {
removeKandevContainers();
// The backend owns containers created by the test task. Do not sweep
// the daemon here: another E2E shard may be using it concurrently.
removeScopedKandevContainers();
}
},
{ scope: "worker", timeout: 120_000 },
@@ -144,13 +151,19 @@ export const dockerTest = backendFixture.extend<
// lazy fixture, so keeping reset there allowed tests that only request
// apiClient/seedData to leave tasks and containers behind for the next test.
dockerTest.beforeEach(async ({ apiClient, seedData }) => {
await apiClient.e2eReset(seedData.workspaceId, [seedData.workflowId]);
removeKandevContainers();
try {
await apiClient.e2eReset(seedData.workspaceId, [seedData.workflowId]);
} finally {
removeScopedKandevContainers();
}
});
dockerTest.afterEach(async ({ apiClient, seedData }) => {
await apiClient.e2eReset(seedData.workspaceId, [seedData.workflowId]);
removeKandevContainers();
try {
await apiClient.e2eReset(seedData.workspaceId, [seedData.workflowId]);
} finally {
removeScopedKandevContainers();
}
});
export { expect } from "@playwright/test";
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { reviewRepositoryName } from "./multi-pr-review";
describe("multi-PR review repository names", () => {
it("normalizes repository IDs while preserving distinct names", () => {
expect(reviewRepositoryName({ repositoryId: "Repo/ID #A" })).toBe("e2e-review-repoida");
expect(reviewRepositoryName({ repositoryId: "Repo/ID #B" })).toBe("e2e-review-repoidb");
});
});
+11 -5
View File
@@ -2,9 +2,14 @@ import type { SeedData } from "../fixtures/test-base";
import type { ApiClient } from "./api-client";
export const REVIEW_OWNER = "testorg";
export const REVIEW_REPO = "testrepo";
export const REVIEW_SHARED_FILE = "shared-pr.ts";
/** Keep the mocked GitHub repository tied to the worker's seeded repository. */
export function reviewRepositoryName(seedData: Pick<SeedData, "repositoryId">): string {
const suffix = seedData.repositoryId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 12);
return `e2e-review-${suffix.toLowerCase()}`;
}
export const REVIEW_PRS = [
{
number: 121,
@@ -28,6 +33,7 @@ export async function seedMultiPRReviewTask(
title: string,
description = "/e2e:simple-message",
) {
const repositoryName = reviewRepositoryName(seedData);
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("reviewer");
@@ -57,15 +63,15 @@ export async function seedMultiPRReviewTask(
base_branch: "main",
author_login: "reviewer",
repo_owner: REVIEW_OWNER,
repo_name: REVIEW_REPO,
html_url: `https://github.com/${REVIEW_OWNER}/${REVIEW_REPO}/pull/${pr.number}`,
repo_name: repositoryName,
html_url: `https://github.com/${REVIEW_OWNER}/${repositoryName}/pull/${pr.number}`,
additions: 1,
deletions: 0,
})),
);
for (const pr of REVIEW_PRS) {
await apiClient.mockGitHubAddPRFiles(REVIEW_OWNER, REVIEW_REPO, pr.number, [
await apiClient.mockGitHubAddPRFiles(REVIEW_OWNER, repositoryName, pr.number, [
{
filename: REVIEW_SHARED_FILE,
status: "added",
@@ -78,7 +84,7 @@ export async function seedMultiPRReviewTask(
workspace_id: seedData.workspaceId,
task_id: task.id,
repository_id: seedData.repositoryId,
pr_url: `https://github.com/${REVIEW_OWNER}/${REVIEW_REPO}/pull/${pr.number}`,
pr_url: `https://github.com/${REVIEW_OWNER}/${repositoryName}/pull/${pr.number}`,
});
}
+6 -3
View File
@@ -3,6 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { SSH_E2E_IMAGE_TAG } from "../fixtures/ssh-image";
import { E2E_DOCKER_SCOPE } from "../fixtures/docker-probe";
import type { SSHServerHandle } from "./ssh";
/**
@@ -36,7 +37,7 @@ const BASTION_BASE_PORT = 23000;
export function startBastionAndTarget(workerIndex: number, workDir: string): SSHBastionHandles {
fs.mkdirSync(workDir, { recursive: true });
const networkName = `kandev-e2e-ssh-net-${workerIndex}`;
const networkName = `kandev-e2e-ssh-net-${E2E_DOCKER_SCOPE}-${workerIndex}`;
ensureNetwork(networkName);
// The SSH executor's ProxyJump string doesn't carry a separate identity for
@@ -48,7 +49,7 @@ export function startBastionAndTarget(workerIndex: number, workDir: string): SSH
const bastionPort = BASTION_BASE_PORT + workerIndex * 2;
const bastion = launchInNetwork({
name: `kandev-bastion-${workerIndex}`,
name: `kandev-bastion-${E2E_DOCKER_SCOPE}-${workerIndex}`,
role: "bastion",
networkName,
hostPort: bastionPort,
@@ -57,7 +58,7 @@ export function startBastionAndTarget(workerIndex: number, workDir: string): SSH
});
const target = launchInNetwork({
name: `kandev-target-${workerIndex}`,
name: `kandev-target-${E2E_DOCKER_SCOPE}-${workerIndex}`,
role: "target",
networkName,
hostPort: null, // not reachable from host
@@ -119,6 +120,8 @@ function launchInNetwork(opts: LaunchOpts): SSHServerHandle {
"--label",
"kandev.managed=true",
"--label",
`kandev.e2e.run=${E2E_DOCKER_SCOPE}`,
"--label",
`kandev.e2e.role=ssh-${opts.role}`,
"--cap-add",
"NET_ADMIN",
+6 -3
View File
@@ -2,6 +2,7 @@ import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { E2E_DOCKER_SCOPE } from "../fixtures/docker-probe";
/**
* Live SSH server handle returned by startSSHServer. Tests use the host/port
@@ -34,8 +35,8 @@ const SSH_BASE_PORT = 22000;
* Start a fresh sshd container for a Playwright worker. Each worker gets:
* - A unique published port (SSH_BASE_PORT + workerIndex).
* - A unique ed25519 keypair generated into workDir.
* - A unique container name `kandev-sshd-e2e-<workerIndex>` (labeled
* `kandev.managed=true` so the shared cleanup hook destroys it).
* - A process-scoped container name and ownership label so cleanup cannot
* remove another shard's SSH target.
*
* Blocks until sshd answers TCP connections and a stable host fingerprint
* is observable via `ssh-keyscan`.
@@ -47,7 +48,7 @@ export function startSSHServer(
): SSHServerHandle {
fs.mkdirSync(workDir, { recursive: true });
const port = SSH_BASE_PORT + workerIndex;
const containerName = `kandev-sshd-e2e-${workerIndex}`;
const containerName = `kandev-sshd-e2e-${E2E_DOCKER_SCOPE}-${workerIndex}`;
const identityFile = path.join(workDir, "id_ed25519");
const publicKeyFile = `${identityFile}.pub`;
@@ -74,6 +75,8 @@ export function startSSHServer(
"--label",
"kandev.managed=true",
"--label",
`kandev.e2e.run=${E2E_DOCKER_SCOPE}`,
"--label",
"kandev.e2e.role=ssh-target",
"--cap-add",
"NET_ADMIN", // iptables-based fault injection
+8 -12
View File
@@ -4,10 +4,9 @@ const CI = !!process.env.CI;
export default defineConfig({
testDir: "./tests",
// `fullyParallel: true` is required so `--shard=N/M` splits work at the test
// level (not the file level). With file-level sharding the suite was wildly
// unbalanced: largest shard ran 12 min, smallest 1.5 min, because spec files
// vary from 1 to 30+ tests. Test-level sharding flattens that distribution.
// `fullyParallel: true` keeps direct local `--shard=N/M` runs at test level.
// CI uses explicit duration-aware file manifests from e2e/scripts instead;
// this setting preserves the existing local debugging behavior.
//
// Concurrency is still capped by `workers: 1` below — only one test runs at a
// time per shard process, preserving the worker-scoped backend invariant that
@@ -24,7 +23,7 @@ export default defineConfig({
// the fixture's baseline env snapshot).
fullyParallel: true,
forbidOnly: CI,
failOnFlakyTests: !CI,
failOnFlakyTests: !CI || process.env.E2E_FAIL_ON_FLAKY === "1",
retries: CI ? 2 : 0,
workers: 1,
timeout: 60_000,
@@ -96,13 +95,10 @@ export default defineConfig({
testMatch: [/docker\/.*\.spec\.ts/, /ssh\/.*\.spec\.ts/],
use: { ...devices["Desktop Chrome"] },
timeout: 180_000,
// Per-test sharding: CI runs `--shard=N/6` to split this project's
// tests across runners. Playwright only shards at the test level
// when fullyParallel is true (otherwise sharding is by file, and
// since this project has a single spec file every test would land
// in shard 1). Each shard is its own process with its own backend,
// and workers:1 still serializes tests within a shard, so this is
// safe.
// Local `--shard=N/6` runs can still split this project at test level.
// CI uses explicit files from the duration-aware containers manifests.
// Each CI shard is its own process, and workers:1 still serializes tests
// within a shard, so the worker-scoped backend remains safe.
fullyParallel: true,
},
],
+253
View File
@@ -0,0 +1,253 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
buildTimingProfile,
collectEligibleSamples,
hasCompleteShardSet,
normalizeReportPath,
parseBlobReportJsonl,
quantile,
readBlobReportJsonl,
readTimingProfile,
type ShardMetadata,
type TimingObservation,
type TimingProfile,
} from "./e2e-timings";
function event(method: string, params: Record<string, unknown>): string {
return JSON.stringify({ method, params });
}
function reportJsonl(): string {
const project = {
name: "chromium",
suites: [
{
title: "",
location: { file: "tests/chat/example.spec.ts", line: 1, column: 1 },
entries: [
{
testId: "test-1",
title: "shows the conversation",
location: { file: "tests/chat/example.spec.ts", line: 10, column: 1 },
},
],
},
],
};
return [
event("onBlobReportMetadata", { version: 2 }),
event("onProject", { project }),
event("onTestBegin", {
testId: "test-1",
result: { id: "result-1", retry: 0, workerIndex: 0, parallelIndex: 0, startTime: 1 },
}),
event("onTestEnd", {
test: { testId: "test-1", expectedStatus: "passed", timeout: 60_000 },
result: { id: "result-1", duration: 1200, status: "passed", errors: [] },
}),
event("onTestBegin", {
testId: "test-1",
result: { id: "result-2", retry: 1, workerIndex: 0, parallelIndex: 0, startTime: 2 },
}),
event("onTestEnd", {
test: { testId: "test-1", expectedStatus: "passed", timeout: 60_000 },
result: { id: "result-2", duration: 6000, status: "passed", errors: [] },
}),
].join("\n");
}
describe("e2e timing collection", () => {
it("normalizes paths relative to Playwright's e2e test root", () => {
expect(normalizeReportPath("task/example.spec.ts")).toBe("tests/task/example.spec.ts");
expect(normalizeReportPath("e2e/tests/task/example.spec.ts")).toBe(
"tests/task/example.spec.ts",
);
});
it("parses project, file, title, retry, and duration from blob events", () => {
const observations = parseBlobReportJsonl(reportJsonl());
expect(observations).toHaveLength(2);
expect(observations[0]).toMatchObject({
project: "chromium",
file: "tests/chat/example.spec.ts",
title: "shows the conversation",
retry: 0,
status: "passed",
durationSeconds: 1.2,
});
expect(observations[1]?.retry).toBe(1);
});
it("reads an unpacked blob report", () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-blob-report-"));
const filePath = path.join(directory, "report.jsonl");
fs.writeFileSync(filePath, reportJsonl());
expect(readBlobReportJsonl(filePath)).toBe(reportJsonl());
});
it("uses only first-attempt passing results as baseline samples", () => {
const observations = parseBlobReportJsonl(reportJsonl());
expect(collectEligibleSamples(observations)).toEqual([
expect.objectContaining({ durationSeconds: 1.2, retry: 0 }),
]);
});
it("computes interpolated quantiles", () => {
expect(quantile([1, 2, 3, 4], 0.5)).toBe(2.5);
expect(quantile([1, 2, 3, 4], 0.75)).toBe(3.25);
expect(quantile([], 0.75)).toBe(0);
});
it.each([
["missing percentile", { p75Seconds: undefined }],
["non-numeric percentile", { p75Seconds: "slow" }],
["non-finite sample", { samples: [{ durationSeconds: null }] }],
])("rejects a malformed timing profile with %s", (_name, override) => {
const webRoot = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-profile-"));
const filePath = path.join(webRoot, "timing-profile.json");
const entry = { ...previousTimingEntry(), ...override };
fs.writeFileSync(filePath, JSON.stringify({ ...previousTimingProfile(), entries: [entry] }));
expect(() => readTimingProfile(filePath)).toThrow(/Unsupported timing profile version/);
});
it("only treats a complete normal and container shard set as a deletion catalog", () => {
const metadata = (cohort: string, shard: number, shardCount: number): ShardMetadata => ({
cohort,
shard,
shardCount,
startedAt: "2026-08-10T10:00:00.000Z",
finishedAt: "2026-08-10T10:00:01.000Z",
exitCode: 0,
predictedSeconds: 1,
unitIds: [],
});
const complete = [
...Array.from({ length: 14 }, (_, index) => metadata("normal", index + 1, 14)),
...Array.from({ length: 6 }, (_, index) => metadata("containers", index + 1, 6)),
];
expect(hasCompleteShardSet(complete)).toBe(true);
expect(hasCompleteShardSet(complete.slice(0, -1))).toBe(false);
});
it("merges and bounds timing history", () => {
const previous: TimingProfile = {
version: 1,
generatedAt: "2026-08-09T00:00:00.000Z",
source: { branch: "main", sha: "old-sha", runId: "10" },
entries: [
{
key: "chromium::tests/chat/example.spec.ts::shows the conversation",
project: "chromium",
file: "tests/chat/example.spec.ts",
title: "shows the conversation",
fileHash: "hash-old",
samples: Array.from({ length: 20 }, (_, index) => ({
durationSeconds: index + 1,
sourceSha: "old-sha",
runId: "10",
recordedAt: "2026-08-09T00:00:00.000Z",
})),
p50Seconds: 10.5,
p75Seconds: 15.25,
lastSeenSha: "old-sha",
lastSeenRunId: "10",
},
],
};
const observations: TimingObservation[] = [
{
key: "chromium::tests/chat/example.spec.ts::shows the conversation",
project: "chromium",
file: "tests/chat/example.spec.ts",
title: "shows the conversation",
retry: 0,
status: "passed",
durationSeconds: 100,
errors: [],
attachments: [],
},
];
const profile = buildTimingProfile(previous, observations, {
branch: "main",
sha: "new-sha",
runId: "11",
generatedAt: "2026-08-10T00:00:00.000Z",
fileHashes: new Map([["tests/chat/example.spec.ts", "hash-new"]]),
knownKeys: new Set([observations[0]!.key]),
});
expect(profile.entries[0]?.samples).toHaveLength(20);
expect(profile.entries[0]?.samples.at(-1)?.durationSeconds).toBe(100);
expect(profile.entries[0]?.fileHash).toBe("hash-new");
expect(profile.entries[0]?.p75Seconds).toBeGreaterThan(15);
});
it("compacts previous entries that are no longer in the current catalog", () => {
const currentKey = "chromium::tests/chat/example.spec.ts::shows the conversation";
const staleEntry = {
...previousTimingEntry(),
key: "chromium::tests/removed.spec.ts::removed test",
file: "tests/removed.spec.ts",
};
const profile = buildTimingProfile(
{ ...previousTimingProfile(), entries: [previousTimingEntry(), staleEntry] },
[
{
key: currentKey,
project: "chromium",
file: "tests/chat/example.spec.ts",
title: "shows the conversation",
retry: 0,
status: "passed",
durationSeconds: 2,
errors: [],
attachments: [],
},
],
{
branch: "main",
sha: "new-sha",
runId: "11",
generatedAt: "2026-08-10T00:00:00.000Z",
fileHashes: new Map([["tests/chat/example.spec.ts", "hash-new"]]),
knownKeys: new Set([currentKey]),
},
);
expect(profile.entries.map((entry) => entry.key)).toEqual([currentKey]);
});
});
function previousTimingEntry(): TimingProfile["entries"][number] {
return {
key: "chromium::tests/chat/example.spec.ts::shows the conversation",
project: "chromium",
file: "tests/chat/example.spec.ts",
title: "shows the conversation",
fileHash: "hash-old",
samples: [],
p50Seconds: 1,
p75Seconds: 1,
lastSeenSha: "old-sha",
lastSeenRunId: "10",
};
}
function previousTimingProfile(): TimingProfile {
return {
version: 1,
generatedAt: "2026-08-09T00:00:00.000Z",
source: { branch: "main", sha: "old-sha", runId: "10" },
entries: [previousTimingEntry()],
};
}
+581
View File
@@ -0,0 +1,581 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const TIMING_PROFILE_VERSION = 1;
export const MAX_SAMPLES_PER_TEST = 20;
export type TimingStatus = "passed" | "failed" | "timedOut" | "skipped" | "interrupted" | "unknown";
export type TimingError = {
message?: string;
value?: string;
stack?: string;
};
export type TimingAttachment = {
name: string;
path?: string;
body?: string;
};
export type TimingObservation = {
key: string;
project: string;
file: string;
title: string;
retry: number;
status: TimingStatus;
durationSeconds: number;
errors: TimingError[];
attachments: TimingAttachment[];
};
export type TimingSample = {
durationSeconds: number;
sourceSha: string;
runId: string;
recordedAt: string;
};
export type TimingEntry = {
key: string;
project: string;
file: string;
title: string;
fileHash: string;
samples: TimingSample[];
p50Seconds: number;
p75Seconds: number;
lastSeenSha: string;
lastSeenRunId: string;
};
export type TimingProfile = {
version: typeof TIMING_PROFILE_VERSION;
generatedAt: string;
source: {
branch: string;
sha: string;
runId: string;
};
entries: TimingEntry[];
};
export type TimingProfileBuildMetadata = {
branch: string;
sha: string;
runId: string;
generatedAt: string;
fileHashes: Map<string, string>;
knownKeys?: Set<string>;
};
export type ShardMetadata = {
cohort: string;
shard: number;
shardCount: number;
startedAt: string;
finishedAt: string;
exitCode: number;
predictedSeconds: number;
unitIds: string[];
};
type JsonRecord = Record<string, unknown>;
type TestDescriptor = {
key: string;
project: string;
file: string;
title: string;
};
function asRecord(value: unknown): JsonRecord | undefined {
return value && typeof value === "object" ? (value as JsonRecord) : undefined;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function asNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function isFiniteDuration(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
export function normalizeReportPath(file: string): string {
const normalized = file.replaceAll("\\", "/").replace(/^\.\//, "");
const testsIndex = normalized.indexOf("tests/");
if (testsIndex >= 0) return normalized.slice(testsIndex);
const e2eIndex = normalized.indexOf("e2e/");
if (e2eIndex >= 0) return normalized.slice(e2eIndex + "e2e/".length);
return normalized ? `tests/${normalized}` : normalized;
}
export function stableTestKey(project: string, file: string, title: string): string {
return `${project}::${normalizeReportPath(file)}::${title}`;
}
function collectProjectTests(project: JsonRecord, descriptors: Map<string, TestDescriptor>): void {
const projectName = asString(project.name, "unknown");
const walkSuite = (suite: JsonRecord, parentTitles: string[]) => {
const suiteTitle = asString(suite.title);
const titles = suiteTitle ? [...parentTitles, suiteTitle] : parentTitles;
const entries = Array.isArray(suite.entries) ? suite.entries : [];
for (const rawEntry of entries) {
const entry = asRecord(rawEntry);
if (!entry) continue;
const testId = asString(entry.testId);
if (testId) {
const location = asRecord(entry.location);
const file = normalizeReportPath(asString(location?.file));
const fileName = path.posix.basename(file);
const title = [...titles.filter((value) => value !== fileName), asString(entry.title)]
.filter(Boolean)
.join(" › ");
descriptors.set(testId, {
key: stableTestKey(projectName, file, title),
project: projectName,
file,
title,
});
continue;
}
walkSuite(entry, titles);
}
};
const suites = Array.isArray(project.suites) ? project.suites : [];
for (const rawSuite of suites) {
const suite = asRecord(rawSuite);
if (suite) walkSuite(suite, []);
}
}
function parseJsonLines(content: string): JsonRecord[] {
const events: JsonRecord[] = [];
for (const line of content.split(/\r?\n/)) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line) as unknown;
const event = asRecord(parsed);
if (event) events.push(event);
} catch {
// A truncated report should not make all other shard timings unusable.
}
}
return events;
}
type BlobParseState = {
descriptors: Map<string, TestDescriptor>;
retries: Map<string, number>;
attachments: Map<string, TimingAttachment[]>;
observations: TimingObservation[];
};
function parseTimingStatus(value: unknown): TimingStatus {
switch (asString(value)) {
case "passed":
case "failed":
case "timedOut":
case "skipped":
case "interrupted":
return asString(value) as TimingStatus;
default:
return "unknown";
}
}
function parseTimingErrors(value: unknown): TimingError[] {
if (!Array.isArray(value)) return [];
return value.flatMap((rawError) => {
const error = asRecord(rawError);
return error
? [
{
message: asString(error.message) || undefined,
value: asString(error.value) || undefined,
stack: asString(error.stack) || undefined,
},
]
: [];
});
}
function parseTimingAttachment(value: unknown): TimingAttachment | undefined {
const attachment = asRecord(value);
if (!attachment) return undefined;
return {
name: asString(attachment.name, "attachment"),
path: asString(attachment.path) || undefined,
body: asString(attachment.base64) || undefined,
};
}
function handleProjectEvent(state: BlobParseState, params: JsonRecord): void {
const project = asRecord(params.project);
if (project) collectProjectTests(project, state.descriptors);
}
function handleTestBeginEvent(state: BlobParseState, params: JsonRecord): void {
const result = asRecord(params.result);
const resultId = asString(result?.id);
if (resultId) state.retries.set(resultId, asNumber(result?.retry));
}
function handleAttachEvent(state: BlobParseState, params: JsonRecord): void {
const resultId = asString(params.resultId);
if (!resultId) return;
const existing = state.attachments.get(resultId) ?? [];
const rawAttachments = Array.isArray(params.attachments) ? params.attachments : [];
for (const rawAttachment of rawAttachments) {
const attachment = parseTimingAttachment(rawAttachment);
if (attachment) existing.push(attachment);
}
state.attachments.set(resultId, existing);
}
function parseTestEndEvent(
state: BlobParseState,
params: JsonRecord,
): TimingObservation | undefined {
const test = asRecord(params.test);
const result = asRecord(params.result);
if (!test || !result) return undefined;
const descriptor = state.descriptors.get(asString(test.testId));
if (!descriptor) return undefined;
const resultId = asString(result.id);
return {
...descriptor,
retry: state.retries.get(resultId) ?? 0,
status: parseTimingStatus(result.status),
durationSeconds: asNumber(result.duration) / 1000,
errors: parseTimingErrors(result.errors),
attachments: state.attachments.get(resultId) ?? [],
};
}
export function parseBlobReportJsonl(content: string): TimingObservation[] {
const state: BlobParseState = {
descriptors: new Map(),
retries: new Map(),
attachments: new Map(),
observations: [],
};
for (const event of parseJsonLines(content)) {
const method = asString(event.method);
const params = asRecord(event.params);
if (!params) continue;
switch (method) {
case "onProject":
handleProjectEvent(state, params);
break;
case "onTestBegin":
handleTestBeginEvent(state, params);
break;
case "onAttach":
handleAttachEvent(state, params);
break;
case "onTestEnd": {
const observation = parseTestEndEvent(state, params);
if (observation) state.observations.push(observation);
break;
}
default:
break;
}
}
return state.observations;
}
export function collectEligibleSamples(observations: TimingObservation[]): TimingObservation[] {
return observations.filter(
(observation) =>
observation.status === "passed" &&
observation.retry === 0 &&
Number.isFinite(observation.durationSeconds) &&
observation.durationSeconds >= 0,
);
}
export function quantile(values: number[], probability: number): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const position = Math.min(1, Math.max(0, probability)) * (sorted.length - 1);
const lower = Math.floor(position);
const upper = Math.ceil(position);
if (lower === upper) return sorted[lower] ?? 0;
const fraction = position - lower;
return (sorted[lower] ?? 0) + ((sorted[upper] ?? 0) - (sorted[lower] ?? 0)) * fraction;
}
export function buildTimingProfile(
previous: TimingProfile | undefined,
observations: TimingObservation[],
metadata: TimingProfileBuildMetadata,
): TimingProfile {
const entries = new Map<string, TimingEntry>();
for (const entry of previous?.entries ?? []) {
if (metadata.knownKeys && !metadata.knownKeys.has(entry.key)) continue;
entries.set(entry.key, {
...entry,
samples: entry.samples.slice(-MAX_SAMPLES_PER_TEST),
});
}
for (const observation of collectEligibleSamples(observations)) {
const previousEntry = entries.get(observation.key);
const samples = [
...(previousEntry?.samples ?? []),
{
durationSeconds: observation.durationSeconds,
sourceSha: metadata.sha,
runId: metadata.runId,
recordedAt: metadata.generatedAt,
},
].slice(-MAX_SAMPLES_PER_TEST);
const durations = samples.map((sample) => sample.durationSeconds);
const fileHash = metadata.fileHashes.get(observation.file) ?? previousEntry?.fileHash ?? "";
entries.set(observation.key, {
key: observation.key,
project: observation.project,
file: observation.file,
title: observation.title,
fileHash,
samples,
p50Seconds: quantile(durations, 0.5),
p75Seconds: quantile(durations, 0.75),
lastSeenSha: metadata.sha,
lastSeenRunId: metadata.runId,
});
}
return {
version: TIMING_PROFILE_VERSION,
generatedAt: metadata.generatedAt,
source: { branch: metadata.branch, sha: metadata.sha, runId: metadata.runId },
entries: [...entries.values()].sort((left, right) => left.key.localeCompare(right.key)),
};
}
export function readTimingProfile(filePath: string): TimingProfile | undefined {
if (!fs.existsSync(filePath)) return undefined;
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<TimingProfile>;
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
const validEntries = entries.every((rawEntry) => {
const entry = asRecord(rawEntry);
if (
!entry ||
!isFiniteDuration(entry.p50Seconds) ||
!isFiniteDuration(entry.p75Seconds) ||
!Array.isArray(entry.samples)
) {
return false;
}
return entry.samples.every((rawSample) => {
const sample = asRecord(rawSample);
return Boolean(sample && isFiniteDuration(sample.durationSeconds));
});
});
if (
parsed.version !== TIMING_PROFILE_VERSION ||
!Array.isArray(parsed.entries) ||
!validEntries
) {
throw new Error(`Unsupported timing profile version in ${filePath}`);
}
return parsed as TimingProfile;
}
export function hashSourceFile(webRoot: string, relativeFile: string): string {
const normalized = normalizeReportPath(relativeFile);
const candidates = [path.resolve(webRoot, normalized), path.resolve(webRoot, "e2e", normalized)];
const filePath = candidates.find((candidate) => {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
}
});
if (!filePath) return "";
return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
}
function listFiles(root: string): string[] {
if (!fs.existsSync(root)) return [];
const stats = fs.statSync(root);
if (stats.isFile()) return [root];
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
const child = path.join(root, entry.name);
return entry.isDirectory() ? listFiles(child) : [child];
});
}
export function findBlobReportFiles(inputPath: string): string[] {
return listFiles(inputPath)
.filter((filePath) => filePath.endsWith(".zip") || filePath.endsWith(".jsonl"))
.sort();
}
export function findShardMetadataFiles(inputPath: string): string[] {
return listFiles(inputPath)
.filter((filePath) => /shard-(normal|containers)-\d+-metadata\.json$/.test(filePath))
.sort();
}
export function readShardMetadata(inputPath: string): ShardMetadata[] {
return findShardMetadataFiles(inputPath).flatMap((filePath) => {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<ShardMetadata>;
if (
typeof parsed.cohort !== "string" ||
typeof parsed.shard !== "number" ||
typeof parsed.shardCount !== "number" ||
typeof parsed.startedAt !== "string" ||
typeof parsed.finishedAt !== "string" ||
typeof parsed.exitCode !== "number" ||
typeof parsed.predictedSeconds !== "number" ||
!Array.isArray(parsed.unitIds)
) {
return [];
}
return [
{
cohort: parsed.cohort,
shard: parsed.shard,
shardCount: parsed.shardCount,
startedAt: parsed.startedAt,
finishedAt: parsed.finishedAt,
exitCode: parsed.exitCode,
predictedSeconds: parsed.predictedSeconds,
unitIds: parsed.unitIds.filter((unitId): unitId is string => typeof unitId === "string"),
},
];
} catch {
return [];
}
});
}
export function hasCompleteShardSet(metadata: ShardMetadata[]): boolean {
const expected = new Map([
["normal", 14],
["containers", 6],
]);
const seen = new Set<string>();
for (const shard of metadata) {
const shardCount = expected.get(shard.cohort);
if (
shardCount === undefined ||
shard.shardCount !== shardCount ||
!Number.isInteger(shard.shard) ||
shard.shard < 1 ||
shard.shard > shardCount
) {
return false;
}
const key = `${shard.cohort}:${shard.shard}`;
if (seen.has(key)) return false;
seen.add(key);
}
return seen.size === 20;
}
export function readBlobReportJsonl(filePath: string): string {
if (filePath.endsWith(".jsonl")) return fs.readFileSync(filePath, "utf8");
const entries = execFileSync("unzip", ["-Z1", filePath], { encoding: "utf8" })
.split(/\r?\n/)
.filter(Boolean);
const reportFile = entries.find((entry) => entry.endsWith("report.jsonl"));
if (!reportFile) throw new Error(`Blob report ${filePath} does not contain report.jsonl`);
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-blob-report-"));
const outputPath = path.join(tempDir, "report.jsonl");
const outputFd = fs.openSync(outputPath, "w");
try {
execFileSync("unzip", ["-p", filePath, reportFile], {
stdio: ["ignore", outputFd, "pipe"],
});
return fs.readFileSync(outputPath, "utf8");
} finally {
fs.closeSync(outputFd);
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
export function parseBlobReports(inputPath: string): TimingObservation[] {
return findBlobReportFiles(inputPath).flatMap((filePath) =>
parseBlobReportJsonl(readBlobReportJsonl(filePath)),
);
}
function parseArguments(argv: string[]): Record<string, string> {
const args: Record<string, string> = {};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (!argument?.startsWith("--")) continue;
const name = argument.slice(2);
const value = argv[index + 1];
if (value && !value.startsWith("--")) {
args[name] = value;
index += 1;
}
}
return args;
}
function runCli(): void {
const args = parseArguments(process.argv.slice(2));
const input = args.input;
const output = args.output;
if (!input || !output) {
throw new Error("Usage: e2e-timings.ts --input <blob-dir> --output <profile.json>");
}
const observations = parseBlobReports(input);
const webRoot = args["web-root"] ?? process.cwd();
const fileHashes = new Map(
[...new Set(observations.map((observation) => observation.file))].map((file) => [
file,
hashSourceFile(webRoot, file),
]),
);
const previous = args.previous ? readTimingProfile(args.previous) : undefined;
const completeShardSet = hasCompleteShardSet(readShardMetadata(input));
const profile = buildTimingProfile(previous, observations, {
branch: args.branch ?? process.env.GITHUB_REF_NAME ?? "unknown",
sha: args.sha ?? process.env.GITHUB_SHA ?? "unknown",
runId: args["run-id"] ?? process.env.GITHUB_RUN_ID ?? "unknown",
generatedAt: args.generated ?? new Date().toISOString(),
fileHashes,
knownKeys: completeShardSet
? new Set(observations.map((observation) => observation.key))
: undefined,
});
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(profile, null, 2)}\n`);
console.log(
`timing profile: ${profile.entries.length} entries, ${collectEligibleSamples(observations).length} new samples`,
);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
runCli();
}
+231
View File
@@ -0,0 +1,231 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
createShardPlan,
discoverTestCatalog,
validateShardManifest,
type CatalogUnit,
} from "./plan-shards";
import type { TimingProfile } from "./e2e-timings";
const profile: TimingProfile = {
version: 1,
generatedAt: "2026-08-10T00:00:00.000Z",
source: { branch: "main", sha: "main-sha", runId: "12" },
entries: [
{
key: "chromium::tests/a.spec.ts::a",
project: "chromium",
file: "tests/a.spec.ts",
title: "a",
fileHash: "hash-a",
samples: [],
p50Seconds: 8,
p75Seconds: 10,
lastSeenSha: "main-sha",
lastSeenRunId: "12",
},
{
key: "chromium::tests/b.spec.ts::b",
project: "chromium",
file: "tests/b.spec.ts",
title: "b",
fileHash: "hash-old-b",
samples: [],
p50Seconds: 20,
p75Seconds: 20,
lastSeenSha: "main-sha",
lastSeenRunId: "12",
},
],
};
const units: CatalogUnit[] = [
{ project: "chromium", file: "tests/a.spec.ts", fileHash: "hash-a", testCount: 1 },
{ project: "chromium", file: "tests/b.spec.ts", fileHash: "hash-new-b", testCount: 1 },
{ project: "chromium", file: "tests/c.spec.ts", fileHash: "hash-c", testCount: 3 },
{ project: "mobile-chrome", file: "tests/mobile-c.spec.ts", fileHash: "hash-m", testCount: 2 },
];
describe("duration-aware shard planning", () => {
it("discovers specs below the web e2e test root and hashes their source", () => {
const webRoot = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-planner-"));
const testDir = path.join(webRoot, "e2e", "tests", "chat");
fs.mkdirSync(testDir, { recursive: true });
fs.symlinkSync(path.resolve("node_modules"), path.join(webRoot, "node_modules"), "dir");
fs.writeFileSync(
path.join(webRoot, "e2e", "playwright.config.ts"),
[
'import { defineConfig } from "@playwright/test";',
"export default defineConfig({",
' testDir: "./tests",',
" projects: [",
' { name: "chromium", testIgnore: [/mobile-.*\\.spec\\.ts$/, /tests\\/(docker|ssh)\\//] },',
' { name: "mobile-chrome", testMatch: /mobile-.*\\.spec\\.ts$/ },',
' { name: "containers", testMatch: /tests\\/(docker|ssh)\\/.*\\.spec\\.ts$/ },',
" ],",
"});",
].join("\n"),
);
fs.writeFileSync(
path.join(testDir, "example.spec.ts"),
[
'import { test } from "@playwright/test";',
'test.describe("suite", () => {',
" test.beforeEach(async () => {});",
' test("works", async () => {});',
' test.skip("skipped", async () => {});',
' for (const name of ["generated one", "generated two"]) {',
" test(name, async () => {});",
" }",
"});",
].join("\n"),
);
expect(discoverTestCatalog(webRoot, "normal")).toEqual([
expect.objectContaining({
project: "chromium",
file: "tests/chat/example.spec.ts",
testCount: 4,
fileHash: expect.stringMatching(/^[a-f0-9]{64}$/),
}),
]);
});
it("assigns weighted units with stable longest-processing-time packing", () => {
const first = createShardPlan(units, {
cohort: "normal",
shardCount: 2,
profile,
generatedAt: "2026-08-10T00:00:00.000Z",
});
const second = createShardPlan(units, {
cohort: "normal",
shardCount: 2,
profile,
generatedAt: "2026-08-10T00:00:00.000Z",
});
expect(first).toEqual(second);
expect(first.shards).toHaveLength(2);
expect(first.shards.flatMap((shard) => shard.units)).toHaveLength(units.length);
expect(first.summary.warmUnits).toBe(1);
expect(first.summary.unknownUnits).toBe(2);
expect(first.shards[0]?.predictedSeconds).toBeGreaterThan(0);
});
it("validates exact coverage and rejects duplicate assignments", () => {
const plan = createShardPlan(units, {
cohort: "containers",
shardCount: 2,
generatedAt: "2026-08-10T00:00:00.000Z",
});
expect(() => validateShardManifest(plan, units)).not.toThrow();
const duplicate = structuredClone(plan);
duplicate.shards[1]?.units.push(plan.shards[0]!.units[0]!);
expect(() => validateShardManifest(duplicate, units)).toThrow(/duplicate/i);
});
it("rejects units whose immutable catalog fields were changed", () => {
const plan = createShardPlan(units, {
cohort: "normal",
shardCount: 2,
generatedAt: "2026-08-10T00:00:00.000Z",
});
const tampered = structuredClone(plan);
const unit = tampered.shards.find((shard) => shard.units.length > 0)?.units[0];
expect(unit).toBeDefined();
unit!.fileHash = "tampered";
expect(() => validateShardManifest(tampered, units)).toThrow(/catalog fields/i);
});
it("rejects duplicate and out-of-range shard indexes", () => {
const plan = createShardPlan(units, {
cohort: "normal",
shardCount: 2,
generatedAt: "2026-08-10T00:00:00.000Z",
});
const duplicate = structuredClone(plan);
duplicate.shards[1]!.index = duplicate.shards[0]!.index;
expect(() => validateShardManifest(duplicate, units)).toThrow(/shard index/i);
const outOfRange = structuredClone(plan);
outOfRange.shards[0]!.index = plan.shardCount + 1;
expect(() => validateShardManifest(outOfRange, units)).toThrow(/shard index/i);
});
it("uses count-based estimates when no profile exists", () => {
const plan = createShardPlan(units.slice(0, 2), {
cohort: "containers",
shardCount: 1,
generatedAt: "2026-08-10T00:00:00.000Z",
});
expect(plan.profile.mode).toBe("count-fallback");
expect(plan.summary.unknownUnits).toBe(2);
expect(plan.shards[0]?.predictedSeconds).toBe(40);
});
it("adds a fallback estimate for new tests in a profiled file", () => {
const plan = createShardPlan(
[{ project: "chromium", file: "tests/a.spec.ts", fileHash: "hash-a", testCount: 2 }],
{
cohort: "normal",
shardCount: 1,
profile,
generatedAt: "2026-08-10T00:00:00.000Z",
},
);
expect(plan.summary.unknownUnits).toBe(1);
expect(plan.shards[0]?.units[0]?.classification).toBe("unknown");
expect(plan.shards[0]?.predictedSeconds).toBe(20);
});
it("reports a file-level unit that dominates its shard", () => {
const plan = createShardPlan(
[
{ project: "chromium", file: "tests/heavy.spec.ts", fileHash: "heavy", testCount: 100 },
{ project: "chromium", file: "tests/light.spec.ts", fileHash: "light", testCount: 1 },
],
{
cohort: "normal",
shardCount: 2,
generatedAt: "2026-08-10T00:00:00.000Z",
},
);
expect(plan.summary.dominantUnits).toEqual([
expect.objectContaining({
id: "chromium::tests/heavy.spec.ts",
shard: 1,
shareOfShard: 1,
}),
]);
});
it("reports profiled files that are no longer in the catalog", () => {
const plan = createShardPlan(units.slice(0, 1), {
cohort: "normal",
shardCount: 1,
profile: {
...profile,
entries: [
...profile.entries,
{
...profile.entries[0]!,
key: "chromium::tests/removed.spec.ts::removed",
file: "tests/removed.spec.ts",
},
],
},
generatedAt: "2026-08-10T00:00:00.000Z",
});
expect(plan.summary.staleUnits).toBe(2);
});
});
+469
View File
@@ -0,0 +1,469 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
hashSourceFile,
normalizeReportPath,
readTimingProfile,
type TimingProfile,
} from "./e2e-timings";
export const SHARD_MANIFEST_VERSION = 1;
export const NORMAL_SHARD_COUNT = 14;
export const CONTAINER_SHARD_COUNT = 6;
export const DEFAULT_NORMAL_TEST_SECONDS = 10;
export const DEFAULT_CONTAINER_TEST_SECONDS = 20;
export const CHANGED_FILE_MULTIPLIER = 1.25;
export const DOMINANT_UNIT_SHARE = 0.5;
export type Cohort = "normal" | "containers";
export type EstimateClassification = "profile" | "warm" | "unknown";
export type CatalogUnit = {
project: string;
file: string;
fileHash: string;
testCount: number;
};
export type PlannedUnit = CatalogUnit & {
id: string;
estimatedSeconds: number;
classification: EstimateClassification;
};
export type Shard = {
index: number;
predictedSeconds: number;
files: string[];
units: PlannedUnit[];
};
export type DominantUnitDiagnostic = {
shard: number;
id: string;
estimatedSeconds: number;
shareOfShard: number;
};
export type ShardManifest = {
version: typeof SHARD_MANIFEST_VERSION;
cohort: Cohort;
shardCount: number;
generatedAt: string;
projects: string[];
profile: {
mode: "main" | "count-fallback";
sourceSha: string;
sourceRunId: string;
};
catalog: {
checksum: string;
unitCount: number;
};
summary: {
predictedSeconds: number;
unknownUnits: number;
warmUnits: number;
staleUnits: number;
targetSeconds: number;
dominantUnits: DominantUnitDiagnostic[];
};
shards: Shard[];
};
type PlaywrightListTest = { projectName?: unknown };
type PlaywrightListSpec = { file?: unknown; tests?: unknown };
type PlaywrightListSuite = { file?: unknown; specs?: unknown; suites?: unknown };
type PlaywrightListReport = { suites: PlaywrightListSuite[] };
const playwrightReportCache = new Map<string, PlaywrightListReport>();
function asSuites(value: unknown): PlaywrightListSuite[] {
return Array.isArray(value)
? value.filter((entry): entry is PlaywrightListSuite =>
Boolean(entry && typeof entry === "object"),
)
: [];
}
function readPlaywrightListReport(webRoot: string): PlaywrightListReport {
const resolvedRoot = path.resolve(webRoot);
const cached = playwrightReportCache.get(resolvedRoot);
if (cached) return cached;
const playwrightCli = createRequire(import.meta.url).resolve("@playwright/test/cli");
const output = execFileSync(
process.execPath,
[
playwrightCli,
"test",
"--config",
path.join(resolvedRoot, "e2e", "playwright.config.ts"),
"--project=chromium",
"--project=mobile-chrome",
"--project=containers",
"--list",
"--reporter=json",
],
{ cwd: resolvedRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
);
const parsed = JSON.parse(output) as { suites?: unknown };
const report = { suites: asSuites(parsed.suites) };
playwrightReportCache.set(resolvedRoot, report);
return report;
}
function collectSpecCounts(
specs: unknown,
suiteFile: unknown,
allowedProjects: ReadonlySet<string>,
counts: Map<string, { project: string; file: string; testCount: number }>,
): void {
if (!Array.isArray(specs)) return;
for (const rawSpec of specs) {
if (!rawSpec || typeof rawSpec !== "object") continue;
const spec = rawSpec as PlaywrightListSpec;
const fileValue = typeof spec.file === "string" ? spec.file : suiteFile;
if (typeof fileValue !== "string") continue;
const file = normalizeReportPath(fileValue);
const tests = Array.isArray(spec.tests) ? spec.tests : [];
for (const rawTest of tests) {
if (!rawTest || typeof rawTest !== "object") continue;
const project = (rawTest as PlaywrightListTest).projectName;
if (typeof project !== "string" || !allowedProjects.has(project)) continue;
const id = `${project}::${file}`;
const current = counts.get(id);
counts.set(id, { project, file, testCount: (current?.testCount ?? 0) + 1 });
}
}
}
function collectPlaywrightCounts(
suites: PlaywrightListSuite[],
allowedProjects: ReadonlySet<string>,
counts: Map<string, { project: string; file: string; testCount: number }>,
): void {
for (const suite of suites) {
collectSpecCounts(suite.specs, suite.file, allowedProjects, counts);
collectPlaywrightCounts(asSuites(suite.suites), allowedProjects, counts);
}
}
export function discoverTestCatalog(webRoot: string, cohort: Cohort): CatalogUnit[] {
const allowedProjects = new Set(
cohort === "containers" ? ["containers"] : ["chromium", "mobile-chrome"],
);
const counts = new Map<string, { project: string; file: string; testCount: number }>();
collectPlaywrightCounts(readPlaywrightListReport(webRoot).suites, allowedProjects, counts);
return [...counts.values()]
.map((unit) => ({ ...unit, fileHash: hashSourceFile(webRoot, unit.file) }))
.sort((left, right) =>
`${left.project}::${left.file}`.localeCompare(`${right.project}::${right.file}`),
);
}
function unitId(unit: Pick<CatalogUnit, "project" | "file">): string {
return `${unit.project}::${normalizeReportPath(unit.file)}`;
}
function catalogChecksum(units: CatalogUnit[]): string {
const value = units
.map((unit) => `${unitId(unit)}:${unit.fileHash}:${unit.testCount}`)
.sort()
.join("\n");
return createHash("sha256").update(value).digest("hex");
}
function fallbackSeconds(cohort: Cohort): number {
return cohort === "containers" ? DEFAULT_CONTAINER_TEST_SECONDS : DEFAULT_NORMAL_TEST_SECONDS;
}
function countStaleProfileUnits(profile: TimingProfile | undefined, units: CatalogUnit[]): number {
if (!profile) return 0;
const currentUnits = new Set(units.map((unit) => unitId(unit)));
const projects = new Set(units.map((unit) => unit.project));
const profileUnits = new Set(
profile.entries
.filter((entry) => projects.has(entry.project))
.map((entry) => unitId({ project: entry.project, file: entry.file })),
);
return [...profileUnits].filter((id) => !currentUnits.has(id)).length;
}
function estimateClassification(missingTestCount: number, warm: boolean): EstimateClassification {
if (missingTestCount > 0) return "unknown";
return warm ? "warm" : "profile";
}
function estimateUnit(
unit: CatalogUnit,
cohort: Cohort,
profile: TimingProfile | undefined,
changedFileMultiplier: number,
): PlannedUnit {
const id = unitId(unit);
const entries = profile?.entries.filter(
(entry) => entry.project === unit.project && normalizeReportPath(entry.file) === unit.file,
);
if (!entries || entries.length === 0) {
return {
...unit,
id,
estimatedSeconds: unit.testCount * fallbackSeconds(cohort),
classification: "unknown",
};
}
const missingTestCount = Math.max(0, unit.testCount - entries.length);
const warm = entries.some((entry) => entry.fileHash !== unit.fileHash);
const estimate =
entries.reduce((total, entry) => total + Math.max(0, entry.p75Seconds), 0) +
missingTestCount * fallbackSeconds(cohort);
return {
...unit,
id,
estimatedSeconds: estimate * (warm ? changedFileMultiplier : 1),
classification: estimateClassification(missingTestCount, warm),
};
}
export function createShardPlan(
units: CatalogUnit[],
options: {
cohort: Cohort;
shardCount: number;
profile?: TimingProfile;
generatedAt: string;
changedFileMultiplier?: number;
},
): ShardManifest {
if (!Number.isInteger(options.shardCount) || options.shardCount < 1) {
throw new Error(`shardCount must be a positive integer, got ${options.shardCount}`);
}
const plannedUnits = units.map((unit) =>
estimateUnit(
unit,
options.cohort,
options.profile,
options.changedFileMultiplier ?? CHANGED_FILE_MULTIPLIER,
),
);
const shards: Shard[] = Array.from({ length: options.shardCount }, (_, index) => ({
index: index + 1,
predictedSeconds: 0,
files: [],
units: [],
}));
const sortedUnits = [...plannedUnits].sort(
(left, right) =>
right.estimatedSeconds - left.estimatedSeconds || left.id.localeCompare(right.id),
);
for (const unit of sortedUnits) {
const shard = shards.reduce((lightest, candidate) => {
if (candidate.predictedSeconds < lightest.predictedSeconds) return candidate;
return candidate.index < lightest.index ? candidate : lightest;
}, shards[0]!);
shard.units.push(unit);
shard.predictedSeconds += unit.estimatedSeconds;
if (!shard.files.includes(unit.file)) shard.files.push(unit.file);
}
for (const shard of shards) {
shard.units.sort((left, right) => left.id.localeCompare(right.id));
shard.files.sort();
}
const predictedSeconds = shards.reduce((total, shard) => total + shard.predictedSeconds, 0);
const targetSeconds = options.shardCount > 0 ? predictedSeconds / options.shardCount : 0;
const staleUnits = countStaleProfileUnits(options.profile, units);
const dominantUnits = shards.flatMap((shard) => {
const dominant = shard.units.reduce<PlannedUnit | undefined>(
(current, unit) =>
!current || unit.estimatedSeconds > current.estimatedSeconds ? unit : current,
undefined,
);
if (!dominant || shard.predictedSeconds <= 0) return [];
const shareOfShard = dominant.estimatedSeconds / shard.predictedSeconds;
const isMaterialOutlier =
dominant.estimatedSeconds > targetSeconds ||
(shareOfShard >= DOMINANT_UNIT_SHARE &&
dominant.estimatedSeconds >= targetSeconds * DOMINANT_UNIT_SHARE);
if (!isMaterialOutlier) {
return [];
}
return [
{
shard: shard.index,
id: dominant.id,
estimatedSeconds: dominant.estimatedSeconds,
shareOfShard,
},
];
});
return {
version: SHARD_MANIFEST_VERSION,
cohort: options.cohort,
shardCount: options.shardCount,
generatedAt: options.generatedAt,
projects: [...new Set(units.map((unit) => unit.project))].sort(),
profile: {
mode: options.profile ? "main" : "count-fallback",
sourceSha: options.profile?.source.sha ?? "",
sourceRunId: options.profile?.source.runId ?? "",
},
catalog: { checksum: catalogChecksum(units), unitCount: units.length },
summary: {
predictedSeconds,
unknownUnits: plannedUnits.filter((unit) => unit.classification === "unknown").length,
warmUnits: plannedUnits.filter((unit) => unit.classification === "warm").length,
staleUnits,
targetSeconds,
dominantUnits,
},
shards,
};
}
function validateShardIndexes(shards: Shard[], shardCount: number): void {
const seen = new Set<number>();
for (const shard of shards) {
const invalid =
!Number.isInteger(shard.index) ||
shard.index < 1 ||
shard.index > shardCount ||
seen.has(shard.index);
if (invalid) throw new Error(`Invalid or duplicate shard index: ${shard.index}`);
seen.add(shard.index);
}
}
function validatePlannedUnit(
unit: PlannedUnit,
expected: Map<string, CatalogUnit>,
seen: Set<string>,
): void {
const catalogUnit = expected.get(unit.id);
if (!catalogUnit) throw new Error(`Unit is outside the catalog: ${unit.id}`);
const matchesCatalog =
unit.project === catalogUnit.project &&
unit.file === catalogUnit.file &&
unit.fileHash === catalogUnit.fileHash &&
unit.testCount === catalogUnit.testCount;
if (!matchesCatalog) throw new Error(`Unit catalog fields do not match: ${unit.id}`);
if (seen.has(unit.id)) throw new Error(`Duplicate unit assignment: ${unit.id}`);
seen.add(unit.id);
}
function validateShardContents(
shard: Shard,
expected: Map<string, CatalogUnit>,
seen: Set<string>,
): void {
for (const unit of shard.units) validatePlannedUnit(unit, expected, seen);
const predicted = shard.units.reduce((total, unit) => total + unit.estimatedSeconds, 0);
if (Math.abs(predicted - shard.predictedSeconds) > 0.000001) {
throw new Error(`Shard ${shard.index} predicted duration does not match its units`);
}
const files = new Set(shard.units.map((unit) => unit.file));
const filesMatch =
files.size === shard.files.length && [...files].every((file) => shard.files.includes(file));
if (!filesMatch) throw new Error(`Shard ${shard.index} file selections do not match its units`);
}
export function validateShardManifest(manifest: ShardManifest, catalog: CatalogUnit[]): void {
if (manifest.version !== SHARD_MANIFEST_VERSION) {
throw new Error(`Unsupported shard manifest version: ${manifest.version}`);
}
if (manifest.shardCount !== manifest.shards.length) {
throw new Error("Shard manifest count does not match its shard entries");
}
validateShardIndexes(manifest.shards, manifest.shardCount);
const expected = new Map(catalog.map((unit) => [unitId(unit), unit]));
const seen = new Set<string>();
for (const shard of manifest.shards) validateShardContents(shard, expected, seen);
const missing = [...expected.keys()].filter((id) => !seen.has(id));
if (missing.length > 0) throw new Error(`Missing catalog units: ${missing.join(", ")}`);
if (manifest.catalog.unitCount !== catalog.length) {
throw new Error("Shard manifest catalog count does not match the discovered catalog");
}
if (manifest.catalog.checksum !== catalogChecksum(catalog)) {
throw new Error("Shard manifest catalog checksum does not match the discovered catalog");
}
}
function parseArguments(argv: string[]): Record<string, string> {
const args: Record<string, string> = {};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (!argument?.startsWith("--")) continue;
const name = argument.slice(2);
const value = argv[index + 1];
if (value && !value.startsWith("--")) {
args[name] = value;
index += 1;
}
}
return args;
}
function writePlans(outputDir: string, plans: ShardManifest[]): void {
fs.mkdirSync(outputDir, { recursive: true });
for (const plan of plans) {
const cohortDir = path.join(outputDir, plan.cohort);
fs.mkdirSync(cohortDir, { recursive: true });
for (const shard of plan.shards) {
fs.writeFileSync(
path.join(cohortDir, `${shard.index}.json`),
`${JSON.stringify(plan, null, 2)}\n`,
);
}
fs.writeFileSync(
path.join(outputDir, `${plan.cohort}-summary.json`),
`${JSON.stringify(plan, null, 2)}\n`,
);
}
}
function runCli(): void {
const args = parseArguments(process.argv.slice(2));
const webRoot = path.resolve(args["web-root"] ?? process.cwd());
const outputDir = path.resolve(args["output-dir"] ?? path.join(webRoot, "e2e", "manifests"));
const profile = args.profile ? readTimingProfile(path.resolve(args.profile)) : undefined;
const generatedAt = args.generated ?? new Date().toISOString();
const normalCatalog = discoverTestCatalog(webRoot, "normal");
const containerCatalog = discoverTestCatalog(webRoot, "containers");
const normal = createShardPlan(normalCatalog, {
cohort: "normal",
shardCount: NORMAL_SHARD_COUNT,
profile,
generatedAt,
});
const containers = createShardPlan(containerCatalog, {
cohort: "containers",
shardCount: CONTAINER_SHARD_COUNT,
profile,
generatedAt,
});
validateShardManifest(normal, normalCatalog);
validateShardManifest(containers, containerCatalog);
writePlans(outputDir, [normal, containers]);
console.log(
JSON.stringify(
{
outputDir,
normal: normal.summary,
containers: containers.summary,
},
null,
2,
),
);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
runCli();
}
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import { summarizeObservations } from "./retry-summary";
import type { ShardMetadata } from "./e2e-timings";
import type { TimingObservation } from "./e2e-timings";
function observation(overrides: Partial<TimingObservation>): TimingObservation {
return {
key: "chromium::tests/example.spec.ts::works",
project: "chromium",
file: "tests/example.spec.ts",
title: "works",
retry: 0,
status: "passed",
durationSeconds: 1,
errors: [],
attachments: [],
...overrides,
};
}
describe("retry summary", () => {
it("distinguishes first-attempt passes, passed retries, and failures", () => {
const summary = summarizeObservations(
[
observation({}),
observation({
key: "chromium::tests/flaky.spec.ts::flaky",
title: "flaky",
status: "failed",
}),
observation({
key: "chromium::tests/flaky.spec.ts::flaky",
title: "flaky",
retry: 1,
status: "passed",
durationSeconds: 2,
errors: [{ message: "temporary failure" }],
}),
observation({
key: "chromium::tests/slow.spec.ts::slow",
title: "slow",
status: "timedOut",
errors: [{ message: "Timeout 60000ms exceeded" }],
}),
observation({
key: "chromium::tests/terminal.spec.ts::terminal",
title: "terminal",
status: "failed",
errors: [{ message: "terminal failure" }],
attachments: [{ name: "trace", path: "resources/trace.zip" }],
}),
],
"2026-08-10T10:02:00.000Z",
{
attachmentArtifact: {
name: "e2e-timing-diagnostics",
url: "https://github.com/kdlbs/kandev/actions/runs/42#artifacts",
pathPrefix: "retry-artifacts",
availablePaths: new Set(["resources/trace.zip"]),
},
},
);
expect(summary.counts).toEqual({
passedFirstAttempt: 1,
passedAfterRetry: 1,
failed: 1,
timedOut: 1,
skipped: 0,
});
expect(summary.tests).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: "chromium::tests/flaky.spec.ts::flaky", attempts: 2 }),
expect.objectContaining({
key: "chromium::tests/slow.spec.ts::slow",
finalStatus: "timedOut",
}),
expect.objectContaining({
key: "chromium::tests/terminal.spec.ts::terminal",
finalStatus: "failed",
errorCategory: "failure",
attachments: [
{
name: "trace",
path: "retry-artifacts/trace.zip",
artifactName: "e2e-timing-diagnostics",
artifactUrl: "https://github.com/kdlbs/kandev/actions/runs/42#artifacts",
},
],
}),
]),
);
});
it("compares planned and actual shard durations and keeps plan health counters", () => {
const metadata: ShardMetadata[] = [
{
cohort: "normal",
shard: 1,
shardCount: 2,
startedAt: "2026-08-10T10:00:00.000Z",
finishedAt: "2026-08-10T10:02:00.000Z",
exitCode: 0,
predictedSeconds: 90,
unitIds: ["chromium::tests/example.spec.ts"],
},
];
const summary = summarizeObservations([], "2026-08-10T10:02:00.000Z", {
shardMetadata: metadata,
planSummaries: [
{
profileMode: "count-fallback",
unknownUnits: 3,
warmUnits: 1,
staleUnits: 2,
targetSeconds: 120,
},
],
});
expect(summary.planning).toEqual({
mode: "count-fallback",
unknownUnits: 3,
warmUnits: 1,
staleUnits: 2,
targetSeconds: 120,
});
expect(summary.shards).toEqual([
expect.objectContaining({
cohort: "normal",
shard: 1,
predictedSeconds: 90,
actualSeconds: 120,
deltaSeconds: 30,
exitCode: 0,
}),
]);
});
});
+325
View File
@@ -0,0 +1,325 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
parseBlobReports,
findBlobReportFiles,
readShardMetadata,
type ShardMetadata,
type TimingAttachment,
type TimingError,
type TimingObservation,
type TimingStatus,
} from "./e2e-timings";
export type RetryAttachment = TimingAttachment & {
artifactName?: string;
artifactUrl?: string;
};
export type RetryTestSummary = {
key: string;
project: string;
file: string;
title: string;
attempts: number;
statuses: TimingStatus[];
finalStatus: TimingStatus;
finalDurationSeconds: number;
errorCategory: "none" | "failure" | "timeout" | "interrupted";
errors: TimingError[];
attachments: RetryAttachment[];
};
export type RetrySummary = {
generatedAt: string;
counts: {
passedFirstAttempt: number;
passedAfterRetry: number;
failed: number;
timedOut: number;
skipped: number;
};
planning: {
mode: "main" | "count-fallback" | "mixed" | "unknown";
unknownUnits: number;
warmUnits: number;
staleUnits: number;
targetSeconds: number;
};
shards: Array<{
cohort: string;
shard: number;
shardCount: number;
predictedSeconds: number;
actualSeconds: number;
deltaSeconds: number;
exitCode: number;
startedAt: string;
finishedAt: string;
}>;
tests: RetryTestSummary[];
};
export type PlanSummaryInput = {
profileMode: "main" | "count-fallback";
unknownUnits: number;
warmUnits: number;
staleUnits: number;
targetSeconds: number;
};
export type RetrySummaryOptions = {
shardMetadata?: ShardMetadata[];
planSummaries?: PlanSummaryInput[];
attachmentArtifact?: {
name: string;
url: string;
pathPrefix: string;
availablePaths: ReadonlySet<string>;
};
};
function retryAttachment(
attachment: TimingAttachment,
artifact: RetrySummaryOptions["attachmentArtifact"],
): RetryAttachment {
const resourcePath = attachment.path?.replaceAll("\\", "/");
if (!artifact || !resourcePath || !artifact.availablePaths.has(resourcePath)) {
return attachment;
}
return {
...attachment,
path: `${artifact.pathPrefix}/${path.posix.basename(resourcePath)}`,
artifactName: artifact.name,
artifactUrl: artifact.url,
};
}
function planMode(planModes: PlanSummaryInput["profileMode"][]): RetrySummary["planning"]["mode"] {
if (planModes.length === 0) return "unknown";
if (planModes.length === 1) return planModes[0]!;
return "mixed";
}
function errorCategory(
status: TimingStatus,
errors: TimingError[],
): RetryTestSummary["errorCategory"] {
if (status === "timedOut" || errors.some((error) => /timeout/i.test(error.message ?? ""))) {
return "timeout";
}
if (status === "interrupted") return "interrupted";
if (status === "failed") return "failure";
return "none";
}
export function summarizeObservations(
observations: TimingObservation[],
generatedAt = new Date().toISOString(),
options: RetrySummaryOptions = {},
): RetrySummary {
const grouped = new Map<string, TimingObservation[]>();
for (const observation of observations) {
const existing = grouped.get(observation.key) ?? [];
existing.push(observation);
grouped.set(observation.key, existing);
}
const tests = [...grouped.values()]
.map((attempts) => {
const ordered = [...attempts].sort((left, right) => left.retry - right.retry);
const finalAttempt = ordered.at(-1)!;
const errors = ordered.flatMap((attempt) => attempt.errors);
return {
key: finalAttempt.key,
project: finalAttempt.project,
file: finalAttempt.file,
title: finalAttempt.title,
attempts: ordered.length,
statuses: ordered.map((attempt) => attempt.status),
finalStatus: finalAttempt.status,
finalDurationSeconds: finalAttempt.durationSeconds,
errorCategory: errorCategory(finalAttempt.status, errors),
errors,
attachments: ordered.flatMap((attempt) =>
attempt.attachments.map((attachment) =>
retryAttachment(attachment, options.attachmentArtifact),
),
),
} satisfies RetryTestSummary;
})
.sort((left, right) => left.key.localeCompare(right.key));
const counts = {
passedFirstAttempt: tests.filter((test) => test.finalStatus === "passed" && test.attempts === 1)
.length,
passedAfterRetry: tests.filter((test) => test.finalStatus === "passed" && test.attempts > 1)
.length,
failed: tests.filter((test) => test.finalStatus === "failed").length,
timedOut: tests.filter((test) => test.finalStatus === "timedOut").length,
skipped: tests.filter((test) => test.finalStatus === "skipped").length,
};
const planModes = [...new Set((options.planSummaries ?? []).map((plan) => plan.profileMode))];
const planning = {
mode: planMode(planModes),
unknownUnits: (options.planSummaries ?? []).reduce(
(total, plan) => total + plan.unknownUnits,
0,
),
warmUnits: (options.planSummaries ?? []).reduce((total, plan) => total + plan.warmUnits, 0),
staleUnits: (options.planSummaries ?? []).reduce((total, plan) => total + plan.staleUnits, 0),
targetSeconds: (options.planSummaries ?? []).reduce(
(total, plan) => total + plan.targetSeconds,
0,
),
};
const shards = (options.shardMetadata ?? []).map((metadata) => {
const actualSeconds = Math.max(
0,
(Date.parse(metadata.finishedAt) - Date.parse(metadata.startedAt)) / 1000,
);
return {
cohort: metadata.cohort,
shard: metadata.shard,
shardCount: metadata.shardCount,
predictedSeconds: metadata.predictedSeconds,
actualSeconds: Number.isFinite(actualSeconds) ? actualSeconds : 0,
deltaSeconds: Number.isFinite(actualSeconds)
? actualSeconds - metadata.predictedSeconds
: -metadata.predictedSeconds,
exitCode: metadata.exitCode,
startedAt: metadata.startedAt,
finishedAt: metadata.finishedAt,
};
});
return { generatedAt, counts, planning, shards, tests };
}
function readPlanSummaries(manifestDir: string): PlanSummaryInput[] {
return ["normal-summary.json", "containers-summary.json"].flatMap((name) => {
const filePath = path.join(manifestDir, name);
if (!fs.existsSync(filePath)) return [];
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as {
profile?: { mode?: PlanSummaryInput["profileMode"] };
summary?: {
unknownUnits?: number;
warmUnits?: number;
staleUnits?: number;
targetSeconds?: number;
};
};
if (!parsed.profile?.mode || !parsed.summary) return [];
return [
{
profileMode: parsed.profile.mode,
unknownUnits: parsed.summary.unknownUnits ?? 0,
warmUnits: parsed.summary.warmUnits ?? 0,
staleUnits: parsed.summary.staleUnits ?? 0,
targetSeconds: parsed.summary.targetSeconds ?? 0,
},
];
} catch {
return [];
}
});
}
function parseArguments(argv: string[]): Record<string, string> {
const args: Record<string, string> = {};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (!argument?.startsWith("--")) continue;
const name = argument.slice(2);
const value = argv[index + 1];
if (value && !value.startsWith("--")) {
args[name] = value;
index += 1;
}
}
return args;
}
function blobResourceLocations(inputPath: string): Map<string, string> {
const locations = new Map<string, string>();
for (const filePath of findBlobReportFiles(inputPath).filter((file) => file.endsWith(".zip"))) {
const entries = execFileSync("unzip", ["-Z1", filePath], { encoding: "utf8" })
.split(/\r?\n/)
.filter((entry) => /^resources\/[A-Za-z0-9._-]+$/.test(entry));
for (const entry of entries) {
if (!locations.has(entry)) locations.set(entry, filePath);
}
}
return locations;
}
export function materializeRetryArtifacts(
inputPath: string,
outputDir: string,
resourcePaths: Iterable<string>,
): Set<string> {
const locations = blobResourceLocations(inputPath);
const available = new Set<string>();
for (const resourcePath of new Set(resourcePaths)) {
const sourceZip = locations.get(resourcePath);
if (!sourceZip) continue;
fs.mkdirSync(outputDir, { recursive: true });
const outputPath = path.join(outputDir, path.posix.basename(resourcePath));
const outputFd = fs.openSync(outputPath, "w");
try {
execFileSync("unzip", ["-p", sourceZip, resourcePath], {
stdio: ["ignore", outputFd, "pipe"],
});
available.add(resourcePath);
} finally {
fs.closeSync(outputFd);
}
}
return available;
}
function runCli(): void {
const args = parseArguments(process.argv.slice(2));
if (!args.input || !args.output) {
throw new Error("Usage: retry-summary.ts --input <blob-dir> --output <summary.json>");
}
const observations = parseBlobReports(args.input);
const attachmentOutput = args["attachment-output"];
const availablePaths = attachmentOutput
? materializeRetryArtifacts(
args.input,
path.resolve(attachmentOutput),
observations.flatMap((observation) =>
observation.attachments.flatMap((attachment) => attachment.path ?? []),
),
)
: new Set<string>();
const attachmentArtifact =
args["artifact-name"] && args["artifact-url"] && attachmentOutput
? {
name: args["artifact-name"],
url: args["artifact-url"],
pathPrefix: path.basename(path.resolve(attachmentOutput)),
availablePaths,
}
: undefined;
const summary = summarizeObservations(observations, new Date().toISOString(), {
shardMetadata: readShardMetadata(args.input),
planSummaries: args["manifest-dir"] ? readPlanSummaries(args["manifest-dir"]!) : [],
attachmentArtifact,
});
fs.mkdirSync(path.dirname(path.resolve(args.output)), { recursive: true });
fs.writeFileSync(args.output, `${JSON.stringify(summary, null, 2)}\n`);
console.log(
`retry summary: ${summary.counts.passedAfterRetry} passed after retry, ${summary.counts.failed} failed`,
);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
runCli();
}
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
manifest_path="${1:-}"
if [[ -z "$manifest_path" ]]; then
echo "usage: run-planned-shard.sh <manifest.json>" >&2
exit 2
fi
shift
exec pnpm exec tsx e2e/scripts/run-planned-shard.ts --manifest "$manifest_path" "$@"
@@ -0,0 +1,134 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
buildPlaywrightArgs,
executeShard,
readManifest,
resolveShard,
writeShardMetadata,
type ShardManifest,
} from "./run-planned-shard";
function manifest(overrides: Partial<ShardManifest> = {}): ShardManifest {
const shard = {
index: 1,
predictedSeconds: 0,
files: [],
units: [],
};
return {
version: 1,
cohort: "normal",
shardCount: 1,
generatedAt: "2026-08-10T00:00:00.000Z",
projects: ["chromium"],
profile: { mode: "count-fallback", sourceSha: "", sourceRunId: "" },
catalog: { checksum: "", unitCount: 0 },
summary: {
predictedSeconds: 0,
unknownUnits: 0,
warmUnits: 0,
staleUnits: 0,
targetSeconds: 0,
dominantUnits: [],
},
shards: [shard],
...overrides,
};
}
describe("planned shard runner", () => {
it("rejects an invalid manifest before catalog validation", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-runner-"));
const filePath = path.join(dir, "invalid.json");
fs.writeFileSync(filePath, JSON.stringify({ version: 999, cohort: "normal", shards: [] }));
expect(() => readManifest(filePath)).toThrow(/Invalid shard manifest/);
});
it("selects the requested shard from E2E_SHARD", () => {
const selected = resolveShard(
manifest({
shardCount: 2,
shards: [
{ index: 1, predictedSeconds: 1, files: ["tests/one.spec.ts"], units: [] },
{ index: 2, predictedSeconds: 2, files: ["tests/two.spec.ts"], units: [] },
],
}),
"2",
);
expect(selected.index).toBe(2);
});
it("writes empty-shard metadata and propagates the child exit code", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-runner-"));
const current = manifest({
shards: [{ index: 1, predictedSeconds: 0, files: [], units: [] }],
});
writeShardMetadata(current, current.shards[0]!, "2026-08-10T00:00:00.000Z", 0, dir);
expect(
JSON.parse(fs.readFileSync(path.join(dir, "shard-normal-1-metadata.json"), "utf8")),
).toMatchObject({
shard: 1,
exitCode: 0,
});
const populated = manifest({
projects: ["chromium", "mobile-chrome"],
shards: [
{
index: 1,
predictedSeconds: 4,
files: ["tests/example.spec.ts"],
units: [],
},
],
});
const spawn = vi.fn(() => ({ status: 7 })) as never;
const exitCode = executeShard(populated, populated.shards[0]!, spawn, dir);
expect(exitCode).toBe(7);
expect(spawn).toHaveBeenCalledWith(
"pnpm",
[
"exec",
"playwright",
"test",
"--config",
"e2e/playwright.config.ts",
"--project=chromium",
"--project=mobile-chrome",
"tests/example.spec.ts",
],
expect.objectContaining({
env: expect.objectContaining({ E2E_PLANNED_SHARD: "1" }),
}),
);
});
it("builds the runner arguments from the manifest projects and files", () => {
const current = manifest({
projects: ["containers"],
shards: [
{
index: 1,
predictedSeconds: 1,
files: ["tests/docker/example.spec.ts"],
units: [],
},
],
});
expect(buildPlaywrightArgs(current, current.shards[0]!)).toEqual([
"playwright",
"test",
"--config",
"e2e/playwright.config.ts",
"--project=containers",
"tests/docker/example.spec.ts",
]);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
CONTAINER_SHARD_COUNT,
discoverTestCatalog,
NORMAL_SHARD_COUNT,
validateShardManifest,
type Shard,
type ShardManifest,
} from "./plan-shards";
export function readManifest(filePath: string): ShardManifest {
if (!fs.existsSync(filePath)) throw new Error(`Shard manifest does not exist: ${filePath}`);
const manifest = JSON.parse(fs.readFileSync(filePath, "utf8")) as ShardManifest;
if (
manifest.version !== 1 ||
!Array.isArray(manifest.shards) ||
(manifest.cohort !== "normal" && manifest.cohort !== "containers")
) {
throw new Error(`Invalid shard manifest: ${filePath}`);
}
const expectedShardCount =
manifest.cohort === "containers" ? CONTAINER_SHARD_COUNT : NORMAL_SHARD_COUNT;
if (manifest.shardCount !== expectedShardCount) {
throw new Error(
`Invalid ${manifest.cohort} shard count: expected ${expectedShardCount}, got ${manifest.shardCount}`,
);
}
validateShardManifest(manifest, discoverTestCatalog(process.cwd(), manifest.cohort));
return manifest;
}
export function resolveShard(
manifest: ShardManifest,
requestedValue = process.env.E2E_SHARD,
): Shard {
const requested = Number(requestedValue ?? path.basename(process.cwd()));
if (Number.isInteger(requested) && requested > 0) {
const shard = manifest.shards.find((candidate) => candidate.index === requested);
if (shard) return shard;
}
if (manifest.shards.length === 1) return manifest.shards[0]!;
throw new Error("E2E_SHARD must identify one shard in a multi-shard manifest");
}
export function writeShardMetadata(
manifest: ShardManifest,
shard: Shard,
startedAt: string,
exitCode: number,
outputDir = path.resolve("e2e/blob-report"),
) {
fs.mkdirSync(outputDir, { recursive: true });
const metadata = {
cohort: manifest.cohort,
shard: shard.index,
shardCount: manifest.shardCount,
startedAt,
finishedAt: new Date().toISOString(),
exitCode,
predictedSeconds: shard.predictedSeconds,
unitIds: shard.units.map((unit) => unit.id),
};
fs.writeFileSync(
path.join(outputDir, `shard-${manifest.cohort}-${shard.index}-metadata.json`),
`${JSON.stringify(metadata, null, 2)}\n`,
);
}
export function buildPlaywrightArgs(
manifest: ShardManifest,
shard: Shard,
extraArgs: string[] = [],
): string[] {
return [
"playwright",
"test",
"--config",
"e2e/playwright.config.ts",
...manifest.projects.map((project) => `--project=${project}`),
...shard.files,
...extraArgs,
];
}
export function executeShard(
manifest: ShardManifest,
shard: Shard,
spawn = spawnSync,
outputDir = path.resolve("e2e/blob-report"),
extraArgs: string[] = [],
): number {
const startedAt = new Date().toISOString();
const result = spawn("pnpm", ["exec", ...buildPlaywrightArgs(manifest, shard, extraArgs)], {
stdio: "inherit",
env: { ...process.env, E2E_PLANNED_SHARD: String(shard.index) },
});
const exitCode = result.status ?? 1;
writeShardMetadata(manifest, shard, startedAt, exitCode, outputDir);
return exitCode;
}
function run(): void {
const manifestPath = process.argv[process.argv.indexOf("--manifest") + 1] ?? process.argv[2];
if (!manifestPath) throw new Error("Usage: run-planned-shard.ts --manifest <manifest.json>");
const manifest = readManifest(path.resolve(manifestPath));
const shard = resolveShard(manifest);
if (shard.files.length === 0) {
writeShardMetadata(manifest, shard, new Date().toISOString(), 0);
console.log(`planned shard ${shard.index}: no tests assigned`);
return;
}
const separator = process.argv.indexOf("--");
const extraArgs = separator >= 0 ? process.argv.slice(separator + 1) : [];
const exitCode = executeShard(manifest, shard, spawnSync, undefined, extraArgs);
process.exitCode = exitCode;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
run();
}
@@ -528,7 +528,7 @@ test.describe("automations — Pull request merged trigger", () => {
workflowStepId: seedData.startStepId,
// e2e:delay gives the test time to delete the target before the archive call
prompt:
'e2e:delay(500)\ne2e:mcp:kandev:archive_task_kandev({"task_id":"{{data.task_id}}"})\ne2e:message("deleted-done")',
'e2e:delay(1000)\ne2e:mcp:kandev:archive_task_kandev({"task_id":"{{data.task_id}}"})\ne2e:message("deleted-done")',
agentProfileId: seedData.agentProfileId,
});
await apiClient.seedTrigger({
@@ -538,16 +538,15 @@ test.describe("automations — Pull request merged trigger", () => {
enabled: true,
});
// Start the fire concurrently with the delete so the target is gone
// before the agent's archive call fires.
const firePromise = apiClient.firePRMerged({
// Wait until the event has created the automation run, then delete the
// source task while the delay keeps the agent before its archive call.
const { run_task_id } = await apiClient.firePRMerged({
taskId: target.id,
automationId: automation.id,
owner: "acme",
repo: "api",
});
await apiClient.deleteTask(target.id);
const { run_task_id } = await firePromise;
// Run must still complete cleanly — MCP error on a deleted task must not crash the turn
await testPage.goto(`/t/${run_task_id}`);
@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import type { Page } from "@playwright/test";
import { test, expect } from "../../fixtures/docker-test-base";
import { E2E_IMAGE_TAG, removeKandevContainers } from "../../fixtures/docker-probe";
import { E2E_DOCKER_SCOPE, E2E_IMAGE_TAG } from "../../fixtures/docker-probe";
import { dockerInspectExists, dockerRemove } from "../../helpers/docker";
function createStoppedContainer(labels: string[]): string {
@@ -52,32 +52,58 @@ async function refreshStorageOverview(page: Page): Promise<void> {
});
}
test.describe.serial("process-scoped container cleanup", () => {
let previousTestContainer = "";
test.afterAll(() => {
if (previousTestContainer && dockerInspectExists(previousTestContainer)) {
dockerRemove(previousTestContainer);
}
});
test("allows a test to leave a process-owned container", () => {
previousTestContainer = createStoppedContainer([
"kandev.managed=true",
`kandev.e2e.run=${E2E_DOCKER_SCOPE}`,
"kandev.task_id=e2e-cleanup-boundary",
]);
expect(dockerInspectExists(previousTestContainer)).toBe(true);
});
test("starts the next test without the previous process-owned container", () => {
expect(dockerInspectExists(previousTestContainer)).toBe(false);
});
});
test("removes only stopped Kandev-labeled containers and gates daemon-wide cleanup", async ({
testPage,
apiClient,
seedData,
}) => {
// Resources carry a process-unique ownership label. The storage API scopes
// managed-container usage to this process, so the count excludes another
// shard's containers while still exercising the exact cleanup contract.
const scopeLabel = `kandev.e2e.run=${E2E_DOCKER_SCOPE}`;
test.setTimeout(240_000);
// This test asserts the *global* count of kandev.managed=true containers the
// daemon reports ("2 managed containers"). The containers project shares one
// Docker daemon across all specs in the worker and only sweeps managed
// containers at worker teardown, so a sibling Docker-executor spec (or one of
// its retries) can leave managed containers behind and inflate the count —
// the flake seen in CI was "5 managed containers" instead of 2. Sweep any
// stray managed containers first so this test counts only its own fixtures.
removeKandevContainers();
const activeTask = await apiClient.createTask(seedData.workspaceId, "Retain active container", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const managed = createStoppedContainer([
"kandev.managed=true",
scopeLabel,
`kandev.task_id=e2e-storage-missing-${Date.now()}`,
]);
const active = createStoppedContainer(["kandev.managed=true", `kandev.task_id=${activeTask.id}`]);
const unrelated = createStoppedContainer(["e2e.storage=unrelated"]);
const active = createStoppedContainer([
"kandev.managed=true",
scopeLabel,
`kandev.task_id=${activeTask.id}`,
]);
const unrelated = createStoppedContainer([scopeLabel, "e2e.storage=unrelated"]);
try {
expect(dockerInspectExists(managed)).toBe(true);
expect(dockerInspectExists(active)).toBe(true);
expect(dockerInspectExists(unrelated)).toBe(true);
await openStorageSettings(testPage);
// The first overview can race Docker client startup and cache an
// unavailable result. Analyze after creating the fixtures so this test
@@ -46,6 +46,11 @@ test.describe("Workspace picker shortcut (global)", () => {
expect(workspaceIds).toContain(second.id);
expect(workspaceIds.length).toBeGreaterThanOrEqual(2);
// The shortcut opens a controlled menu. Wait for Radix's focus handoff
// before sending navigation keys, otherwise the first ArrowDown can land
// on the document body on a busy CI runner.
await expect(testPage.getByRole("menu")).toBeFocused();
// Focus lands on the menu itself, so the first ArrowDown moves to item 1
// and the second to item 2 — no mouse needed at any point.
await testPage.keyboard.press("ArrowDown");
@@ -2,14 +2,14 @@ import { test, expect } from "../../fixtures/test-base";
import {
REVIEW_OWNER,
REVIEW_PRS,
REVIEW_REPO,
REVIEW_SHARED_FILE,
reviewRepositoryName,
seedMultiPRReviewTask,
} from "../../helpers/multi-pr-review";
import { SessionPage } from "../../pages/session-page";
import type { Page } from "@playwright/test";
async function openMobileReview(testPage: Page, session: SessionPage) {
async function openMobileReview(testPage: Page, session: SessionPage, repositoryName: string) {
await testPage.getByRole("button", { name: "Changes" }).tap();
const changesPanel = testPage.getByTestId("mobile-changes-panel");
await expect(changesPanel).toBeVisible({ timeout: 15_000 });
@@ -18,7 +18,7 @@ async function openMobileReview(testPage: Page, session: SessionPage) {
for (const pr of REVIEW_PRS) {
await expect(
prFiles.locator(
`[data-changes-file=${JSON.stringify(REVIEW_SHARED_FILE)}][data-pr-key="${REVIEW_OWNER}/${REVIEW_REPO}/${pr.number}"]`,
`[data-changes-file=${JSON.stringify(REVIEW_SHARED_FILE)}][data-pr-key="${REVIEW_OWNER}/${repositoryName}/${pr.number}"]`,
),
).toBeVisible();
}
@@ -35,30 +35,34 @@ test.describe("Review dialog multi-PR selector on mobile", () => {
seedData,
}) => {
const task = await seedMultiPRReviewTask(apiClient, seedData, "Mobile Multi-PR Review E2E");
const repositoryName = reviewRepositoryName(seedData);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
await openMobileReview(testPage, session);
await openMobileReview(testPage, session, repositoryName);
const [firstPR, secondPR] = REVIEW_PRS;
const selector = session.reviewPRSelectorTrigger();
await expect(selector).toBeVisible();
await expect(selector).toHaveAttribute("data-pr-number", String(firstPR.number));
await expect(session.reviewFileHeader(REVIEW_SHARED_FILE)).toBeVisible();
await expect(session.reviewDialog().getByTestId("changes-repo-group")).toHaveAttribute(
"data-repository-name",
/.+/,
{ timeout: 30_000 },
);
// The initial diff group can use the generated task-workspace scope, so
// only require a non-empty identity before selecting the second PR.
const repositoryGroup = session.reviewDialog().getByTestId("changes-repo-group");
await expect(repositoryGroup).toHaveAttribute("data-repository-name", /\S+/, {
timeout: 30_000,
});
const repositoryScope = await repositoryGroup.getAttribute("data-repository-name");
expect(repositoryScope).toBeTruthy();
await expect
.poll(() => session.reviewDiffText(), { timeout: 30_000 })
.toContain(firstPR.marker);
await selector.tap();
const menu = session.reviewPRSelectorMenu();
const secondItem = session.reviewPRSelectorItem(REVIEW_OWNER, REVIEW_REPO, secondPR.number);
const secondItem = session.reviewPRSelectorItem(REVIEW_OWNER, repositoryName, secondPR.number);
await expect(menu).toBeVisible();
await expect(secondItem).toBeVisible();
@@ -89,11 +93,9 @@ test.describe("Review dialog multi-PR selector on mobile", () => {
await expect(session.reviewDialog()).toBeVisible();
await expect(selector).toHaveAttribute("data-pr-number", String(secondPR.number));
await expect(session.reviewFileHeader(REVIEW_SHARED_FILE)).toBeVisible({ timeout: 20_000 });
await expect(session.reviewDialog().getByTestId("changes-repo-group")).toHaveAttribute(
"data-repository-name",
/.+/,
{ timeout: 30_000 },
);
await expect(repositoryGroup).toHaveAttribute("data-repository-name", secondPR.repositoryName, {
timeout: 30_000,
});
await expect
.poll(() => session.reviewDiffText(), { timeout: 30_000 })
.toContain(secondPR.marker);
@@ -113,6 +115,7 @@ test.describe("Review dialog multi-PR selector on mobile", () => {
}) => {
await testPage.setViewportSize({ width: 820, height: 900 });
const task = await seedMultiPRReviewTask(apiClient, seedData, "Tablet Multi-PR Review E2E");
const repositoryName = reviewRepositoryName(seedData);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
@@ -135,7 +138,7 @@ test.describe("Review dialog multi-PR selector on mobile", () => {
expect(toolbarBox.height).toBeGreaterThanOrEqual(44);
await dialogSelector.tap();
await session.reviewPRSelectorItem(REVIEW_OWNER, REVIEW_REPO, REVIEW_PRS[1].number).tap();
await session.reviewPRSelectorItem(REVIEW_OWNER, repositoryName, REVIEW_PRS[1].number).tap();
await expect(dialogSelector).toHaveAttribute("data-pr-number", String(REVIEW_PRS[1].number));
await expect.poll(() => session.reviewDiffText()).toContain(REVIEW_PRS[1].marker);
});
@@ -2,20 +2,20 @@ import { test, expect } from "../../fixtures/test-base";
import {
REVIEW_OWNER,
REVIEW_PRS,
REVIEW_REPO,
REVIEW_SHARED_FILE,
reviewRepositoryName,
seedMultiPRReviewTask,
} from "../../helpers/multi-pr-review";
import { SessionPage } from "../../pages/session-page";
async function openDesktopReview(session: SessionPage) {
async function openDesktopReview(session: SessionPage, repositoryName: string) {
await session.clickTab("Changes");
const prFiles = session.prFilesSection();
await expect(prFiles).toBeVisible({ timeout: 20_000 });
for (const pr of REVIEW_PRS) {
await expect(
prFiles.locator(
`[data-changes-file=${JSON.stringify(REVIEW_SHARED_FILE)}][data-pr-key="${REVIEW_OWNER}/${REVIEW_REPO}/${pr.number}"]`,
`[data-changes-file=${JSON.stringify(REVIEW_SHARED_FILE)}][data-pr-key="${REVIEW_OWNER}/${repositoryName}/${pr.number}"]`,
),
).toBeVisible();
}
@@ -32,23 +32,27 @@ test.describe("Review dialog multi-PR selector", () => {
seedData,
}) => {
const task = await seedMultiPRReviewTask(apiClient, seedData, "Multi-PR Review E2E");
const repositoryName = reviewRepositoryName(seedData);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
await openDesktopReview(session);
await openDesktopReview(session, repositoryName);
const [firstPR, secondPR] = REVIEW_PRS;
const selector = session.reviewPRSelectorTrigger();
await expect(selector).toBeVisible();
await expect(selector).toHaveAttribute("data-pr-number", String(firstPR.number));
await expect(session.reviewFileHeader(REVIEW_SHARED_FILE)).toBeVisible();
await expect(session.reviewDialog().getByTestId("changes-repo-group")).toHaveAttribute(
"data-repository-name",
/.+/,
{ timeout: 30_000 },
);
// The initial diff group can use the generated task-workspace scope, so
// only require a non-empty identity before selecting the second PR.
const repositoryGroup = session.reviewDialog().getByTestId("changes-repo-group");
await expect(repositoryGroup).toHaveAttribute("data-repository-name", /\S+/, {
timeout: 30_000,
});
const repositoryScope = await repositoryGroup.getAttribute("data-repository-name");
expect(repositoryScope).toBeTruthy();
await expect
.poll(() => session.reviewDiffText(), { timeout: 30_000 })
.toContain(firstPR.marker);
@@ -56,17 +60,15 @@ test.describe("Review dialog multi-PR selector", () => {
await selector.click();
const menu = session.reviewPRSelectorMenu();
await expect(menu).toBeVisible();
await session.reviewPRSelectorItem(REVIEW_OWNER, REVIEW_REPO, secondPR.number).click();
await session.reviewPRSelectorItem(REVIEW_OWNER, repositoryName, secondPR.number).click();
await expect(menu).toBeHidden();
await expect(session.reviewDialog()).toBeVisible();
await expect(selector).toHaveAttribute("data-pr-number", String(secondPR.number));
await expect(session.reviewFileHeader(REVIEW_SHARED_FILE)).toBeVisible({ timeout: 20_000 });
await expect(session.reviewDialog().getByTestId("changes-repo-group")).toHaveAttribute(
"data-repository-name",
/.+/,
{ timeout: 30_000 },
);
await expect(repositoryGroup).toHaveAttribute("data-repository-name", secondPR.repositoryName, {
timeout: 30_000,
});
await expect
.poll(() => session.reviewDiffText(), { timeout: 30_000 })
.toContain(secondPR.marker);
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { waitForLatestSessionDone } from "../../helpers/session";
import { SessionPage } from "../../pages/session-page";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
@@ -24,12 +25,23 @@ function startConfigSession(apiClient: ApiClient, seedData: SeedData, prompt: st
/** Navigate to session page and wait for the final marker message. */
async function runAndWait(
testPage: import("@playwright/test").Page,
apiClient: ApiClient,
taskId: string,
marker: string,
) {
await waitForLatestSessionDone(
apiClient,
taskId,
1,
`config chat session should complete before reading ${marker}`,
30_000,
);
await testPage.goto(`/t/${taskId}`);
const page = new SessionPage(testPage);
await page.waitForLoad();
// The API completion is the readiness signal. The idle wait allows the
// config-chat WS subscription to replay the completed turn into the UI.
await page.waitForChatIdle({ timeout: 30_000 });
await expect(page.activeChat().getByText(marker, { exact: true })).toBeVisible({
timeout: 60_000,
});
@@ -66,7 +78,7 @@ test.describe("Config-mode MCP — workflow management", () => {
].join("\n"),
);
const page = await runAndWait(testPage, session.task_id, "Done listing");
const page = await runAndWait(testPage, apiClient, session.task_id, "Done listing");
await expandToolCallGroups(page);
await expect(page.chat.getByText("Kandev: List Workspaces")).toBeVisible({ timeout: 10_000 });
await expect(page.chat.getByText("Kandev: List Workflows")).toBeVisible({ timeout: 10_000 });
@@ -86,7 +98,7 @@ test.describe("Config-mode MCP — workflow management", () => {
].join("\n"),
);
await runAndWait(testPage, session.task_id, "Steps listed");
await runAndWait(testPage, apiClient, session.task_id, "Steps listed");
// Verify via API
const { steps } = await apiClient.listWorkflowSteps(workflow.id);
@@ -125,7 +137,7 @@ test.describe("Config-mode MCP — workflow management", () => {
].join("\n"),
);
await runAndWait(testPage, session.task_id, "Step created");
await runAndWait(testPage, apiClient, session.task_id, "Step created");
// Verify all fields via API
const { steps } = await apiClient.listWorkflowSteps(workflow.id);
@@ -150,7 +162,7 @@ test.describe("Config-mode MCP — workflow management", () => {
].join("\n"),
);
await runAndWait(testPage, session.task_id, "Workflow created");
await runAndWait(testPage, apiClient, session.task_id, "Workflow created");
// Verify workflow was created via API.
// Filter by both name and description since the seed workflow shares the same name.
@@ -174,7 +186,7 @@ test.describe("Config-mode MCP — workflow management", () => {
].join("\n"),
);
await runAndWait(testPage, session.task_id, "Workflow updated");
await runAndWait(testPage, apiClient, session.task_id, "Workflow updated");
// Verify via API
const { workflows } = await apiClient.listWorkflows(seedData.workspaceId);
@@ -200,7 +212,7 @@ test.describe("Config-mode MCP — workflow management", () => {
].join("\n"),
);
await runAndWait(testPage, session.task_id, "Workflow deleted");
await runAndWait(testPage, apiClient, session.task_id, "Workflow deleted");
// Verify workflow was deleted via API
const { workflows } = await apiClient.listWorkflows(seedData.workspaceId);
@@ -234,7 +246,7 @@ test.describe("Config-mode MCP — workflow management", () => {
].join("\n"),
);
await runAndWait(testPage, session.task_id, "Step updated");
await runAndWait(testPage, apiClient, session.task_id, "Step updated");
// Verify via API
const { steps } = await apiClient.listWorkflowSteps(workflow.id);
@@ -269,7 +281,7 @@ test.describe("Config-mode MCP — agent management", () => {
].join("\n"),
);
const page = await runAndWait(testPage, session.task_id, "Agents listed");
const page = await runAndWait(testPage, apiClient, session.task_id, "Agents listed");
await expandToolCallGroups(page);
await expect(page.chat.getByText("Kandev: List Agents")).toBeVisible({ timeout: 10_000 });
await expect(page.chat.getByText("Kandev: List Agent Profiles")).toBeVisible({
@@ -297,7 +309,7 @@ test.describe("Config-mode MCP — agent management", () => {
].join("\n"),
);
await runAndWait(testPage, createSession.task_id, "Profile created");
await runAndWait(testPage, apiClient, createSession.task_id, "Profile created");
// Verify profile was created via API
const { agents: afterCreate } = await apiClient.listAgents();
@@ -320,7 +332,7 @@ test.describe("Config-mode MCP — agent management", () => {
].join("\n"),
);
await runAndWait(testPage, deleteSession.task_id, "Profile deleted");
await runAndWait(testPage, apiClient, deleteSession.task_id, "Profile deleted");
// Verify profile was deleted via API
const { agents: afterDelete } = await apiClient.listAgents();
@@ -343,7 +355,7 @@ test.describe("Config-mode MCP — agent management", () => {
].join("\n"),
);
await runAndWait(testPage, session.task_id, "Agent updated");
await runAndWait(testPage, apiClient, session.task_id, "Agent updated");
// Verify via API
const { agents: updated } = await apiClient.listAgents();
@@ -362,7 +374,7 @@ test.describe("Config-mode MCP — agent management", () => {
].join("\n"),
);
await runAndWait(testPage, session.task_id, "Profile updated");
await runAndWait(testPage, apiClient, session.task_id, "Profile updated");
// Verify via API
const { agents } = await apiClient.listAgents();
@@ -393,7 +405,7 @@ test.describe("Config-mode MCP — agent management", () => {
].join("\n"),
);
await runAndWait(testPage, session.task_id, "Profile settings updated");
await runAndWait(testPage, apiClient, session.task_id, "Profile settings updated");
// Verify via API
const profile = await apiClient.getAgentProfile(seedData.agentProfileId);
@@ -421,7 +433,7 @@ test.describe("Config-mode MCP — MCP server configuration", () => {
].join("\n"),
);
const page = await runAndWait(testPage, session.task_id, "MCP config updated");
const page = await runAndWait(testPage, apiClient, session.task_id, "MCP config updated");
await expandToolCallGroups(page);
await expect(page.chat.getByText("Kandev: Get MCP Config")).toBeVisible({ timeout: 10_000 });
await expect(page.chat.getByText("Kandev: Update MCP Config")).toBeVisible({ timeout: 10_000 });
@@ -455,7 +467,7 @@ test.describe("Config-mode MCP — task management", () => {
].join("\n"),
);
const page = await runAndWait(testPage, session.task_id, "Tasks listed");
const page = await runAndWait(testPage, apiClient, session.task_id, "Tasks listed");
await expandToolCallGroups(page);
await expect(page.chat.getByText("Kandev: List Tasks").first()).toBeVisible({
timeout: 10_000,
@@ -483,7 +495,7 @@ test.describe("Config-mode MCP — task management", () => {
].join("\n"),
);
await runAndWait(testPage, session.task_id, "Task moved");
await runAndWait(testPage, apiClient, session.task_id, "Task moved");
// Verify the task is now in the target step
const { tasks } = await apiClient.listTasks(seedData.workspaceId);
@@ -507,7 +519,7 @@ test.describe("Config-mode MCP — task management", () => {
].join("\n"),
);
await runAndWait(testPage, session.task_id, "Task archived");
await runAndWait(testPage, apiClient, session.task_id, "Task archived");
// Verify the task no longer appears in active task list
const { tasks } = await apiClient.listTasks(seedData.workspaceId);
@@ -530,7 +542,7 @@ test.describe("Config-mode MCP — task management", () => {
].join("\n"),
);
await runAndWait(testPage, session.task_id, "Task deleted");
await runAndWait(testPage, apiClient, session.task_id, "Task deleted");
// Verify the task is gone
const { tasks } = await apiClient.listTasks(seedData.workspaceId);
@@ -554,7 +566,7 @@ test.describe("Config-mode MCP — executor management", () => {
].join("\n"),
);
const page = await runAndWait(testPage, session.task_id, "Executors listed");
const page = await runAndWait(testPage, apiClient, session.task_id, "Executors listed");
await expandToolCallGroups(page);
await expect(page.chat.getByText("Kandev: List Executors", { exact: true })).toBeVisible({
timeout: 10_000,
@@ -581,7 +593,7 @@ test.describe("Config-mode MCP — executor management", () => {
].join("\n"),
);
await runAndWait(testPage, createSession.task_id, "Executor profile created");
await runAndWait(testPage, apiClient, createSession.task_id, "Executor profile created");
// Verify via API
({ executors } = await apiClient.listExecutors());
@@ -600,7 +612,7 @@ test.describe("Config-mode MCP — executor management", () => {
].join("\n"),
);
await runAndWait(testPage, deleteSession.task_id, "Executor profile deleted");
await runAndWait(testPage, apiClient, deleteSession.task_id, "Executor profile deleted");
// Verify deleted
const { executors: afterDelete } = await apiClient.listExecutors();
@@ -634,7 +646,7 @@ test.describe("Config-mode MCP — multi-tool workflow", () => {
].join("\n"),
);
await runAndWait(testPage, session.task_id, "Multi-tool config complete");
await runAndWait(testPage, apiClient, session.task_id, "Multi-tool config complete");
// Verify the step was actually created
const { steps } = await apiClient.listWorkflowSteps(workflow.id);
@@ -666,7 +678,7 @@ test.describe("Config-mode MCP — multi-tool workflow", () => {
].join("\n"),
);
await runAndWait(testPage, session.task_id, "Full setup complete");
await runAndWait(testPage, apiClient, session.task_id, "Full setup complete");
// Verify workflow step
const { steps } = await apiClient.listWorkflowSteps(workflow.id);
@@ -106,6 +106,17 @@ test.describe("Mobile task autopilot", () => {
hasText: "Mobile Autopilot Child",
});
await expect(childRow).toBeVisible({ timeout: 30_000 });
// The API state can settle before the sidebar's WS-backed task row has
// hydrated. Gate on both rendered state markers instead of asserting the
// first render, which was the source of the intermittent missing-icon
// failure.
await expect
.poll(
async () =>
`${await childRow.getByTestId("task-autopilot-icon").count()}:${await childRow.getByTestId("task-state-waiting-for-input").count()}`,
{ timeout: 30_000, message: "autopilot waiting state should reach the task switcher" },
)
.toBe("1:1");
await expect(childRow.getByTestId("task-autopilot-icon")).toBeVisible();
await expect(childRow.getByTestId("task-state-waiting-for-input")).toBeVisible({
timeout: 15_000,
@@ -1,10 +1,19 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import type { Page } from "@playwright/test";
const PROMPT_NAME = "e2e-bug-template";
const PROMPT_CONTENT = "Reproduce, isolate, fix with a regression test.";
const MENU_TITLE = /Mention tasks, files, prompts/i;
async function clearTaskCreateDrafts(page: Page): Promise<void> {
await page.evaluate(() => {
for (const key of Object.keys(window.sessionStorage)) {
if (key.startsWith("kandev.taskCreateDraft.")) window.sessionStorage.removeItem(key);
}
});
}
test.describe("Task creation: custom prompt autocomplete", () => {
test.afterEach(async ({ apiClient }) => {
const { prompts } = await apiClient.listPrompts();
@@ -25,6 +34,7 @@ test.describe("Task creation: custom prompt autocomplete", () => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await clearTaskCreateDrafts(testPage);
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
@@ -51,6 +61,7 @@ test.describe("Task creation: custom prompt autocomplete", () => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await clearTaskCreateDrafts(testPage);
await kanban.createTaskButton.first().click();
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
@@ -70,10 +81,12 @@ test.describe("Task creation: custom prompt autocomplete", () => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await clearTaskCreateDrafts(testPage);
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
await expect(testPage.getByTestId("task-description-input")).toHaveValue("");
// Fill title so the form would be otherwise submittable.
await testPage.getByTestId("task-title-input").fill("autocomplete-enter-test");
@@ -0,0 +1,88 @@
# Duration-aware E2E sharding uses rolling `main` timings
**Status:** accepted
**Date:** 2026-08-10
**Area:** infra, workflow
## Context
The E2E workflow currently uses ordinal Playwright sharding. Test counts are
balanced, but test durations are not. The investigated fourteen-shard normal
run ranged from 3m52 to 17m57, and the six-shard container run ranged from
about 3m to 9m59. New tests and changed test behavior would make committed
shard assignments increasingly stale.
The planner needs recent timings without creating a new service or requiring a
developer to edit CI whenever the suite changes. It also needs a safe answer
when the timing artifact is missing, a test is new, or a file changed after
its timing sample was recorded.
## Decision
Use a rolling timing profile from successful `main` E2E runs as the source of
truth for duration-aware planning. Store it as a bounded workflow artifact
containing project/file/title keys, recent first-attempt passing samples, file
hashes, p50/p75 values, and source metadata. On each successful `main` run,
merge new eligible samples into the previous profile and compact the history.
Pull request runs may publish timing diagnostics but cannot update the
baseline.
Generate normal and container manifests per run. Manifests are ephemeral,
validated artifacts and contain explicit project/file selections plus predicted
costs. The initial planner uses deterministic longest-processing-time bin
packing at the project/file level, with stable tie-breaking. Playwright keeps
one worker per shard. A later test-level split is allowed only for measured
file outliers.
The planner uses p75 for unchanged entries, applies a conservative multiplier
to entries whose current source-file hash differs, and falls back to a
project/cohort estimate and then a deterministic count-based plan for unknown
or unavailable data. Every fallback is reported. Invalid or incomplete
manifests fail the planning job rather than silently mixing duration-aware and
ordinal assignments.
## Consequences
- Adding tests requires no shard-file maintenance; discovery and the next
`main` profile update include them automatically.
- Duration changes converge through the bounded sample history instead of
depending on a manual timing refresh.
- The first run after a source change is intentionally conservative and
reports warm entries, which can temporarily overestimate a shard.
- A missing artifact cannot block CI because the count-based fallback remains
deterministic and visible.
- Timing data is retained in GitHub Actions artifacts, so its retention and
access permissions must be maintained with the workflow.
- File-level planning is simple and stable, but a single very large file can
still limit balance; test-level splitting remains a measured extension.
## Implementation status
Accepted and implemented in `.github/workflows/e2e-tests.yml`, the E2E timing,
planner, runner, and retry scripts, and the scoped E2E/backend fixtures. The
initial worker default and matrix layout remain unchanged pending the measured
rollout checks described in the plan.
## Alternatives considered
### Commit shard assignments
Rejected because new tests and timing changes would require manual edits and
would make stale assignments look authoritative.
### Use the latest pull request timings as the baseline
Rejected because a pull request may contain partial coverage, intentional
stress tests, or transient failures. Successful `main` is the comparable,
stable population.
### Keep Playwright's ordinal shard selection
Rejected because it ignores the observed duration skew and cannot express the
profile/fallback information needed to keep assignments current.
### Store timings in an external service
Deferred. An artifact is sufficient for this workflow, avoids new credentials
and availability dependencies, and can be replaced later behind the profile
contract if the suite grows beyond artifact needs.
+1
View File
@@ -143,4 +143,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-08-go-launcher-owns-all-launch-modes | [The Go Launcher Owns Every Entrypoint; apps/cli Is a Publish-Only Shim](2026-08-08-go-launcher-owns-all-launch-modes.md) | accepted | backend, cli, infra | 2026-08-08 |
| 2026-08-09-bind-automation-mutations-to-event-targets | [Bind Automation Mutations to Event Targets](2026-08-09-bind-automation-mutations-to-event-targets.md) | accepted | backend, agentctl, protocol, security, workflow | 2026-08-09 |
| 2026-08-09-exclusive-runtime-state-ownership | [Lock Runtime State Before Backend Startup](2026-08-09-exclusive-runtime-state-ownership.md) | accepted | backend, infra | 2026-08-09 |
| 2026-08-10-duration-aware-e2e-sharding | [Duration-aware E2E sharding uses rolling `main` timings](2026-08-10-duration-aware-e2e-sharding.md) | accepted | infra, workflow | 2026-08-10 |
| 2026-08-10-debug-launcher-profile-selection | [Keep Debug Launches on the Production Profile](2026-08-10-debug-launcher-profile-selection.md) | accepted | backend, frontend, cli | 2026-08-10 |
@@ -0,0 +1,281 @@
---
spec: ../../specs/e2e-duration-aware-sharding/spec.md
created: 2026-08-10
status: in_progress
---
# Implementation Plan: Duration-aware E2E sharding and CI reliability
## Overview
Replace ordinal E2E sharding with generated, duration-aware manifests while
keeping the current Playwright project boundaries and one worker per shard.
Build the timing profile from successful `main` runs, keep the generated
manifests ephemeral, and retain a deterministic count-based fallback. In the
same rollout, repair the seven retry groups identified during PR #2471,
publish passed-after-retry evidence, isolate container state, and run measured
experiments for worker concurrency, mobile project layout, and CI setup cost.
The baseline to compare against is the investigated run: normal shard 2/14
completed in 17m57 while shard 11/14 completed in 3m52; container shard 1/6
completed in 9m59 while the lightest shards took about 3m. Build time was
6m18, including roughly 4m48 for the backend and E2E web build. The plan must
report whether improvements come from balancing test work or from reducing
fixed setup overhead.
No production application behavior is changed. This is a CI/tooling and E2E
fixture package.
## Timing profile and catalog
Create a small, unit-tested timing module under `apps/web/e2e/scripts/`.
- Parse the merged Playwright blob results and normalize each result to the
stable project/file/full-title key.
- Accept only first-attempt passing results as baseline samples. Exclude
failures, timeouts, and retry attempts.
- Merge the current eligible samples into the previous `main` profile and
retain a bounded recent history per key. Compute p50 and p75 values and
record the source commit, source file hash, run ID, and timestamps.
- Discover the current project/file catalog for the normal and container
cohorts. Keep the test matching rules in
`apps/web/e2e/playwright.config.ts` authoritative rather than duplicating
project selection in CI.
- Classify profile entries as unchanged, warm (source hash changed), unknown,
or stale. Apply the changed-file multiplier and project/cohort fallback
values from the spec, and expose the classification in planner output.
Likely files:
- `apps/web/e2e/scripts/e2e-timings.ts`
- `apps/web/e2e/scripts/e2e-timings.test.ts`
- `apps/web/e2e/scripts/plan-shards.ts` (shared catalog types may live here)
- `apps/web/e2e/scripts/plan-shards.test.ts`
## Weighted shard planner
Implement deterministic longest-processing-time bin packing for each cohort.
The planner should produce a versioned JSON manifest with:
- cohort and selected projects;
- exact shard count;
- profile source and fallback mode;
- per-shard explicit project/file selections;
- predicted seconds, unknown count, warm count, and unit count;
- the full catalog checksum and validation result.
Use project/file pairs as the first indivisible unit. If a file is selected by
more than one project, account for each project result in its estimate while
preserving a single unambiguous invocation. Sort by descending estimated cost
and use stable path/project ordering for ties. Validate exact coverage and no
overlap before the manifest can be uploaded.
Keep a seam for a future `file:line` unit. Add a planner diagnostic that
identifies files which dominate their assigned shard so test-level splitting
can be enabled only when measurements justify its complexity.
## CI lifecycle and runner integration
Update `.github/workflows/e2e-tests.yml` so the build/planning job:
1. resolves the latest successful `main` timing-profile artifact with
`actions: read` permission;
2. falls back to an empty/count-based profile when that artifact is absent;
3. runs catalog discovery and creates `normal/1.json` through
`normal/14.json` and `containers/1.json` through `containers/6.json`
manifests;
4. validates and uploads those manifests as a run-scoped artifact.
Update both matrix jobs to download their cohort manifest and invoke a shared
runner. The runner must preserve the existing project filters, retries,
timeouts, blob reporter, output directories, `KANDEV_E2E_WS_ASSERT`, and
`workers: 1`. It must fail clearly for a missing shard manifest and must not
silently add ordinal `--shard` selection.
Update the report job to retain the existing merged report and upload:
- the merged timing profile candidate;
- retry/flakiness summary;
- predicted-versus-actual shard timing summary;
- plan validation and fallback counters.
Only successful `main` report artifacts may be consumed as the next baseline.
Document the artifact names, retention expectations, fallback behavior, and
local dry-run commands in `apps/web/e2e/README.md` and keep workflow comments
aligned with the new ownership model.
## E2E reliability repairs
Implement the observed repairs as focused regression changes:
- exact and scoped disabled-integration navigation locator;
- multi-PR review helper and desktop/mobile expectations based on the seeded
repository name;
- task-create prompt autocomplete setup that clears or explicitly verifies
restored session-storage drafts;
- config-management synchronization tied to the actual update completion
state rather than a brittle marker-only wait;
- mobile autopilot assertion that waits for the sidebar state to converge after
the API/WS transition;
- Docker storage-maintenance setup and assertions scoped to resources owned by
the test, with unique labels and no global managed-container deletion.
Preserve the existing worker-scoped backend isolation. If an application
helper must change to expose a stable readiness signal, add the smallest
helper contract and cover its failure path.
## Retry observability and policy
Add a parser/summary path beside the timing collector that groups Playwright
results by stable test key and records attempts, final status, error category,
and links to trace/screenshot/video artifacts when present. Include counts for
passed-first-attempt, passed-after-retry, failed, timeout, and skipped.
Keep the current PR retry behavior during initial rollout, but make retry
groups visible in the job summary and artifact. Add a scheduled or explicitly
diagnostic workflow mode with `failOnFlakyTests: true`; it should demonstrate
that the suite can enforce a no-flake policy without changing the normal PR
gate as part of this package.
## Controlled performance experiments
After the manifest path is stable, add reproducible experiment commands and a
record format rather than changing defaults immediately.
- Run `workers: 2` on the known heavy normal shards and compare wall time,
CPU/memory pressure, backend isolation, and retries against `workers: 1`.
- Compare the unified normal matrix with a separate mobile matrix using the
same profile. Include browser/backend setup cost before judging the split.
- Measure the fixed costs observed in the run: build, pnpm install, runtime
image startup, and `/ms-playwright` extraction. Benchmark caching or
pre-baked images only after the test-work balance is visible.
The default remains `workers: 1`, the current project matrix, and the existing
setup path until an experiment records a repeatable improvement without a
flake or resource regression.
## Tests
### Unit tests
- timing result parsing, stable identity, retry exclusion, quantiles, bounded
merge history, file-hash warm classification, and fallback selection;
- planner bin packing, stable ties, empty/unknown catalogs, project/file
aggregation, changed-file multiplier, exact coverage, overlap rejection,
and manifest schema validation;
- retry grouping and artifact-link extraction;
- container ownership label construction and scoped cleanup predicates where
those helpers are extracted from the E2E fixtures.
Run the focused tooling tests with:
```sh
cd apps
pnpm --filter @kandev/web test -- e2e/scripts/e2e-timings.test.ts e2e/scripts/plan-shards.test.ts
```
### E2E and CI checks
Run each repaired group directly against the existing config and retain the
normal retry count while debugging. The container test requires a Docker
daemon and the container profile:
```sh
cd apps/web
pnpm exec playwright test --config e2e/playwright.config.ts --project=chromium e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts e2e/tests/review/review-multi-pr.spec.ts e2e/tests/task/task-create-prompt-autocomplete.spec.ts
pnpm exec playwright test --config e2e/playwright.config.ts --project=chromium e2e/tests/settings/config-management.spec.ts
pnpm exec playwright test --config e2e/playwright.config.ts --project=mobile-chrome e2e/tests/review/mobile-review-multi-pr.spec.ts e2e/tests/task/mobile-autopilot-mode.spec.ts
KANDEV_E2E_CONTAINERS=1 pnpm exec playwright test --config e2e/playwright.config.ts --project=containers e2e/tests/docker/storage-maintenance.spec.ts
```
The workflow validation must prove that both cohorts execute every catalog
unit exactly once from generated manifests and that report artifacts include
the profile and retry summary. The three-run success criterion in the spec is
an operational rollout check, not a local unit-test substitute.
## Implementation Waves And Parallel Candidates
Wave 1 — independent evidence and fixture repairs:
- [x] [Task 01: Build the rolling timing profile](task-01-timing-profile.md)
- [x] [Task 04: Repair deterministic E2E fixtures](task-04-deterministic-e2e-fixtures.md)
- [x] [Task 05: Repair asynchronous E2E synchronization](task-05-async-e2e-synchronization.md)
- [x] [Task 06: Isolate container E2E state](task-06-container-isolation.md)
These tasks touch separate primary areas and are parallel candidates, but the
primary implementation conversation should execute them sequentially unless
the user explicitly delegates work.
Wave 2 — planner:
- [x] [Task 02: Generate duration-aware shard manifests](task-02-shard-planner.md)
Depends on Task 01.
Wave 3 — workflow integration:
- [x] [Task 03: Wire manifests into the CI workflow](task-03-ci-manifest-lifecycle.md)
Depends on Tasks 01 and 02.
Wave 4 — reliability signal and measurement:
- [x] [Task 07: Surface retry and timing evidence](task-07-retry-observability.md)
- [x] [Task 08: Run concurrency and setup experiments](task-08-performance-experiments.md)
Both depend on Task 03. Task 08 also consumes the repaired container behavior
from Task 06.
Wave 5 — rollout documentation:
- [x] [Task 09: Document rollout and operating checks](task-09-rollout-documentation.md)
Depends on Tasks 03, 06, and 07 so the README describes the final contracts.
## Verification Results
Implemented and verified in the working tree:
- Timing, planner, retry-summary, runner, and helper tests: 5 files, 26 tests
passed.
- Backend Docker-scope tests: `go test ./internal/agent/runtime/lifecycle
./internal/backendapp` passed, 1,456 tests across 2 packages.
- Web typecheck and focused ESLint passed; Prettier check and `git diff --check`
passed.
- `make build-backend build-web-e2e` and
`make -C apps/backend e2e-plugin-package` passed.
- Planner dry-run generated and validated 14 normal plus 6 container manifests;
the live catalog contained 599 unknown normal units and 27 unknown container
units. The heaviest container file was reported as a 240-second dominant
indivisible unit (63.2% of its shard). The missing-manifest runner check
failed closed as designed.
- Reliability groups passed: normal Chromium 5/5, config-management 21/21,
mobile 3/3, containers 1/1. Two concurrent container runs also passed 1/1
each, and no managed containers remained afterward.
- Task 08 completed its controlled local experiment on the rebuilt merged
checkout. The heavy normal shard passed 191/191 three times with workers=1
(median 458.237s, CV 0.014%). A workers=2 run was 268.715s but produced two
flaky tests and exited 1 under `E2E_FAIL_ON_FLAKY=1`, so workers=2 is rejected.
- The unified-vs-mobile split simulation predicted 611.1s for the current
unified shape versus 653.7s for the best separate allocation before setup
overhead. Normal 14/container 6 remains the measured shape. CI setup was
separately recorded: 13s install, 4m44s backend/web build, 33s browser
extraction; cold-cache measurements remain a follow-up.
- The latest PR run [31469837346](https://github.com/kdlbs/kandev/actions/runs/31469837346)
passed 42 checks at merged head `d150554a5` and reduced full workflow wall
time from 28m30s on main run [31423650879](https://github.com/kdlbs/kandev/actions/runs/31423650879)
to 22m39s (20.5%). Both runs used count fallback because the latest main
run has no timing-profile artifact; the first post-merge main run remains
the profile bootstrap check.
The three-successful-main-run balance criterion and cold-cache setup comparison
remain operational rollout checks after merge, not reasons to change the
shipping defaults now.
## Open Questions
No blocking questions remain for implementation. The initial defaults are a
bounded recent history, p75 planning, a conservative changed-file multiplier,
file-level units, and `workers: 1`. Task 01 may tune the history window and
Task 02 may tune the fallback estimate using real profile distributions, but
those changes must remain explicit in the profile metadata and tests.
@@ -0,0 +1,70 @@
---
id: "01-timing-profile"
title: "Build the rolling timing profile"
status: completed
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/e2e-duration-aware-sharding/spec.md"
---
# Task 01: Build the rolling timing profile
## Acceptance
- Merged Playwright blob results produce stable project/file/full-title keys
and exclude failures, timeouts, and retry attempts from baseline samples.
- A successful `main` profile merges a bounded recent sample history and
exposes p50/p75, source file hash, source run, and source commit metadata.
- New, changed, stale, and unavailable-profile cases select explicit fallback
estimates and are reported to the planner instead of being silently treated
as current timings.
## Verification
```sh
cd apps
pnpm --filter @kandev/web test -- e2e/scripts/e2e-timings.test.ts
pnpm --filter @kandev/web typecheck
```
The unit suite must cover duplicate results, retry-only passes, missing timing
data, changed source hashes, bounded history compaction, and quantile
calculation.
## Files likely touched
- `apps/web/e2e/scripts/e2e-timings.ts`
- `apps/web/e2e/scripts/e2e-timings.test.ts`
- `apps/web/e2e/scripts/plan-shards.ts` (shared profile/catalog types only if
needed)
## Dependencies
None. Use the Playwright blob result shape already emitted by CI and standard
Node/TypeScript dependencies already present in `apps/web`.
## Parallelism
Independent of the fixture repair tasks. The planner task depends on the
profile schema and classification contract from this task.
## Inputs
- The timing profile and fallback contracts in the linked spec and ADR.
- Current Playwright blob reports from `.github/workflows/e2e-tests.yml`.
- Existing `apps/web/e2e/playwright.config.ts` project matching rules.
## Output contract
Report the profile schema, accepted-sample rule, history bound, p50/p75
implementation, fallback classifications, files changed, and exact targeted
test result. Keep the output format versioned so a malformed or old artifact is
rejected by the planner.
## Implementation result
Implemented `e2e-timings.ts` with versioned profiles, bounded 20-sample
histories, p50/p75 quantiles, stable project/file/title keys, source hashing,
and malformed-blob tolerance. The focused timing/planner/retry suite passed
26 tests across 5 files; web typecheck also passed.
@@ -0,0 +1,76 @@
---
id: "02-shard-planner"
title: "Generate duration-aware shard manifests"
status: completed
wave: 2
depends_on:
- "01-timing-profile"
plan: "plan.md"
spec: "../../specs/e2e-duration-aware-sharding/spec.md"
---
# Task 02: Generate duration-aware shard manifests
## Acceptance
- The planner creates deterministic fourteen-shard normal and six-shard
container manifests using weighted project/file units and stable
longest-processing-time bin packing.
- Manifest validation rejects missing, duplicated, out-of-cohort, or
unaccounted catalog units and records predicted cost, unknown count, warm
count, profile source, and fallback mode.
- The planner identifies indivisible files that dominate a shard and leaves a
tested seam for later `file:line` splitting without enabling it by default.
## Verification
```sh
cd apps
pnpm --filter @kandev/web test -- e2e/scripts/plan-shards.test.ts
pnpm --filter @kandev/web typecheck
```
Tests must cover stable tie-breaking, repeated planning with the same inputs,
empty/unknown catalogs, project aggregation, changed-file multipliers, exact
coverage, overlap rejection, and malformed manifest versions.
## Files likely touched
- `apps/web/e2e/scripts/plan-shards.ts`
- `apps/web/e2e/scripts/plan-shards.test.ts`
- `apps/web/e2e/scripts/e2e-timings.ts` (only for shared types or fallback
resolution)
## Dependencies
Depends on Task 01's versioned profile schema, stable test identity, and
classification/fallback contract.
## Parallelism
Sequential after Task 01. CI wiring must consume the manifest shape created
here rather than reimplementing assignment logic in YAML or shell.
## Inputs
- The current normal and container project definitions.
- The profile/catalog types and fallback rules from Task 01.
- The shard counts currently defined in `.github/workflows/e2e-tests.yml`.
## Output contract
Report the manifest schema, bin-packing rule, coverage validation behavior,
sample predicted distributions, heavy-file diagnostics, files changed, and
exact targeted test result. Include a small checked-in fixture input in the
unit tests, not a generated CI manifest.
## Implementation result
Implemented deterministic LPT planning and strict manifest validation. The
local dry-run produced 14 normal and 6 container manifests with complete
catalog coverage; the containers cohort reported the Docker launch file as a
dominant indivisible unit at 240 predicted seconds and 63.2% of its shard.
The focused planner tests passed as part of the 15-test tooling run.
Review remediation replaced source-regex counting with Playwright's discovered
project/test catalog. The planner now matches all 2,103 discovered tests (1,989
normal and 114 container tests after the new cleanup regression cases).
@@ -0,0 +1,81 @@
---
id: "03-ci-manifest-lifecycle"
title: "Wire manifests into the CI workflow"
status: completed
wave: 3
depends_on:
- "01-timing-profile"
- "02-shard-planner"
plan: "plan.md"
spec: "../../specs/e2e-duration-aware-sharding/spec.md"
---
# Task 03: Wire manifests into the CI workflow
## Acceptance
- The build/planning job obtains the latest successful `main` profile when
available, falls back visibly when it is not, validates both cohorts, and
uploads run-scoped normal/container manifests.
- Normal and container matrix jobs download their own manifest and execute
explicit selections through one shared runner while preserving current
project filters, retries, timeouts, blob reporting, environment, output
isolation, and `workers: 1`; they do not silently use ordinal `--shard`.
- The report job emits a profile candidate, retry summary, predicted-versus-
actual timing summary, and validation/fallback counters, while only
successful `main` artifacts can become the next baseline.
## Verification
```sh
cd apps
pnpm --filter @kandev/web test -- e2e/scripts/e2e-timings.test.ts e2e/scripts/plan-shards.test.ts
pnpm --filter @kandev/web typecheck
cd ..
git diff --check
```
The workflow review must confirm the permission needed to read another run's
artifact, stable artifact names, failure behavior for missing manifests, and
that every matrix command receives a shard-specific explicit file list.
## Files likely touched
- `.github/workflows/e2e-tests.yml`
- `apps/web/e2e/scripts/run-planned-shard.sh`
- `apps/web/e2e/scripts/e2e-timings.ts`
- `apps/web/e2e/scripts/plan-shards.ts`
## Dependencies
Depends on Tasks 01 and 02. Do not duplicate profile parsing or bin packing in
the workflow.
## Parallelism
Sequential. This task changes the single CI execution path and must land only
after the manifest and fallback unit contracts are tested.
## Inputs
- Current build, normal matrix, container matrix, and report jobs in
`.github/workflows/e2e-tests.yml`.
- GitHub Actions artifact download permissions and run selection behavior.
- Existing `apps/web/e2e/scripts/run-e2e.sh` environment and output
conventions.
## Output contract
Report the exact job/data flow, artifact names, permission changes, fallback
behavior, command-line invocation, files changed, and local verification
results. Include a dry-run or fixture-based proof that a shard cannot run
without its validated manifest.
## Implementation result
The workflow now fetches the latest successful `main` profile when available,
creates and uploads run-scoped `normal/<n>.json` and `containers/<n>.json`
manifests, runs both matrices through the shared validated runner, and uploads
profile/retry/diagnostic artifacts. A missing-manifest invocation failed closed
with the expected error. Backend/web builds and the focused type/lint checks
passed.
@@ -0,0 +1,74 @@
---
id: "04-deterministic-e2e-fixtures"
title: "Repair deterministic E2E fixtures"
status: completed
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/e2e-duration-aware-sharding/spec.md"
---
# Task 04: Repair deterministic E2E fixtures
## Acceptance
- The disabled-integrations navigation assertion targets one intended link and
no longer fails because several visible links match `/GitHub/`.
- Desktop and mobile multi-PR review tests derive the expected repository name
from the seeded repository/task fixture, including dynamically generated
names.
- Prompt-autocomplete coverage starts from a verified empty or intentionally
seeded draft and cannot inherit an unrelated session-storage draft from a
prior test.
## Verification
```sh
cd apps/web
pnpm exec playwright test --config e2e/playwright.config.ts --project=chromium e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts e2e/tests/review/review-multi-pr.spec.ts e2e/tests/task/task-create-prompt-autocomplete.spec.ts
pnpm exec playwright test --config e2e/playwright.config.ts --project=mobile-chrome e2e/tests/review/mobile-review-multi-pr.spec.ts
```
Each regression must fail for the original selector, repository-name, or
restored-draft defect before the repair and pass without increasing retries or
timeouts.
## Files likely touched
- `apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts`
- `apps/web/e2e/helpers/multi-pr-review.ts`
- `apps/web/e2e/tests/review/review-multi-pr.spec.ts`
- `apps/web/e2e/tests/review/mobile-review-multi-pr.spec.ts`
- `apps/web/e2e/tests/task/task-create-prompt-autocomplete.spec.ts`
- `apps/web/components/task-create-dialog-state.ts` or
`apps/web/e2e/fixtures/test-base.ts` if the smallest isolation boundary is
there
## Dependencies
None. The changes are independent of the shard planner.
## Parallelism
Parallel candidate with Tasks 01, 05, and 06 because it changes deterministic
test fixtures and selectors only.
## Inputs
- The seven retry groups observed in PR #2471.
- Dynamic repository creation in `apps/web/e2e/helpers/multi-pr-review.ts`.
- Draft restore/save behavior in
`apps/web/components/task-create-dialog-state.ts`.
## Output contract
Report each original failure mechanism, the deterministic fixture or locator
change, targeted test results for desktop and mobile, and any state-cleanup
contract added for future tests.
## Implementation result
The desktop regression set passed 5/5 and the mobile multi-PR test passed as
part of the 3/3 mobile run. The fixes use an exact integration href, derive
review repositories from the seeded ID, and clear all task-create draft keys
before autocomplete assertions.
@@ -0,0 +1,70 @@
---
id: "05-async-e2e-synchronization"
title: "Repair asynchronous E2E synchronization"
status: completed
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/e2e-duration-aware-sharding/spec.md"
---
# Task 05: Repair asynchronous E2E synchronization
## Acceptance
- Config-management coverage waits for the actual MCP update completion state
and preserves useful error evidence when the state does not converge; it
does not rely on a marker that can be missed by a 30-second race.
- Mobile autopilot coverage waits for the API/WS/UI hydration boundary before
checking the sidebar state icon and distinguishes a missing event from a
missing render.
- Both regressions pass repeatedly with existing timeouts and expose a
bounded, state-aware wait instead of a larger arbitrary timeout.
## Verification
```sh
cd apps/web
pnpm exec playwright test --config e2e/playwright.config.ts --project=chromium e2e/tests/settings/config-management.spec.ts
pnpm exec playwright test --config e2e/playwright.config.ts --project=mobile-chrome e2e/tests/task/mobile-autopilot-mode.spec.ts
```
Repeat each targeted command enough to exercise the original retry path and
retain traces or logs when convergence fails.
## Files likely touched
- `apps/web/e2e/tests/settings/config-management.spec.ts`
- `apps/web/e2e/tests/task/mobile-autopilot-mode.spec.ts`
- the smallest existing E2E API, WS, or page helper needed to expose a stable
readiness assertion
## Dependencies
None. Preserve the current worker-scoped backend and WebSocket fixture
contracts.
## Parallelism
Parallel candidate with Tasks 01, 04, and 06. It must not modify the shard
planner or global retry policy.
## Inputs
- `runAndWait` and its exact-marker behavior in the config-management spec.
- The mobile autopilot API poll and sidebar assertion ordering.
- Existing Playwright trace, network, and WebSocket diagnostics.
## Output contract
Report the observed event/order race, the readiness condition chosen, targeted
repeat results, and evidence that the fix does not merely increase timeout or
retry values.
## Implementation result
Config-chat now waits for the backend session to complete before reading the
transcript, and the model round-trip uses an advertised mock model so later
tests cannot inherit an unsupported profile value. The full config-management
suite passed 21/21. Mobile autopilot waits for both hydrated state icons and
passed its focused test within the existing timeout.
@@ -0,0 +1,72 @@
---
id: "06-container-isolation"
title: "Isolate container E2E state"
status: completed
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/e2e-duration-aware-sharding/spec.md"
---
# Task 06: Isolate container E2E state
## Acceptance
- Storage-maintenance tests create and count only resources owned by the
current test or worker, using unique labels/identifiers rather than a global
`kandev.managed=true` count.
- Setup and teardown remove only resources created by that test and remain
safe when another container E2E shard is running on the same daemon.
- The original first-attempt timeout path has a regression assertion or
diagnostic that distinguishes resource leakage from genuine slow work.
## Verification
```sh
cd apps/web
KANDEV_E2E_CONTAINERS=1 pnpm exec playwright test --config e2e/playwright.config.ts --project=containers e2e/tests/docker/storage-maintenance.spec.ts
```
Run the targeted test with at least two container processes when the local
Docker daemon permits it, and verify that cleanup does not touch unrelated
Kandev-labelled resources.
## Files likely touched
- `apps/web/e2e/tests/docker/storage-maintenance.spec.ts`
- `apps/web/e2e/fixtures/docker-test-base.ts`
- `apps/web/e2e/fixtures/docker-probe.ts`
- related Docker/SSH fixture helpers only if ownership labels cross the shared
boundary
## Dependencies
None for implementation. Task 08's performance experiment depends on this
task's scoped cleanup guarantee.
## Parallelism
Parallel candidate with Tasks 01, 04, and 05. Coordinate only if a shared
Docker fixture helper is also being changed by another repair.
## Inputs
- The global cleanup and count behavior in the Docker fixture.
- The storage-maintenance test's `removeKandevContainers()` call and comments.
- The existing container project timeout and worker lifecycle.
## Output contract
Report the ownership label/identifier scheme, cleanup scope, concurrent-shard
test evidence, targeted result, and any daemon prerequisites. Do not solve the
flake by raising the 180-second test timeout.
## Implementation result
Docker ownership now propagates from the E2E process into backend-created
containers and storage accounting. The storage-maintenance test passed once
and in two concurrent container processes; no managed containers remained
afterward. Backend lifecycle/storage tests passed in the focused Go packages.
Review remediation now runs process-scoped container removal after every API
reset. A serial regression proves the following test cannot observe a scoped
container left by its predecessor; all three storage-maintenance tests passed.
@@ -0,0 +1,73 @@
---
id: "07-retry-observability"
title: "Surface retry and timing evidence"
status: completed
wave: 4
depends_on:
- "03-ci-manifest-lifecycle"
plan: "plan.md"
spec: "../../specs/e2e-duration-aware-sharding/spec.md"
---
# Task 07: Surface retry and timing evidence
## Acceptance
- Every merged run publishes a retry summary with stable test key, attempts,
final status, error category, and available trace/screenshot/video links.
- The report compares predicted and actual shard durations and records unknown,
warm, fallback, and passed-after-retry counts.
- A scheduled or explicit diagnostic mode can enforce `failOnFlakyTests: true`
without changing the normal PR retry gate during initial rollout.
## Verification
```sh
cd apps
pnpm --filter @kandev/web test -- e2e/scripts/retry-summary.test.ts
pnpm --filter @kandev/web typecheck
cd ..
git diff --check
```
Add fixture-based tests for first-attempt pass, pass-after-retry, final
failure, timeout, and missing artifact links.
## Files likely touched
- `.github/workflows/e2e-tests.yml`
- `apps/web/e2e/scripts/retry-summary.ts`
- `apps/web/e2e/scripts/retry-summary.test.ts`
- `apps/web/e2e/playwright.config.ts` or a diagnostic config override
## Dependencies
Depends on Task 03's report artifact flow and the timing identity from Task 01.
## Parallelism
Sequential after CI manifest integration. It shares the report job and must
not make the timing collector accept retry samples.
## Inputs
- Current blob report merge job and Playwright retry fields.
- The seven PR #2471 retry groups and the persistent failures observed in the
adjacent PR run.
- Existing trace, screenshot, video, and job-summary artifact paths.
## Output contract
Report the summary schema, job-summary presentation, diagnostic-mode trigger,
normal PR policy, files changed, and exact focused test results.
## Implementation result
Implemented retry grouping with final status, error categories, attachments,
and predicted-versus-actual shard deltas. The report job uploads the retry and
timing diagnostics and supports `fail_on_flaky=true` without changing normal
PR retries. The focused retry-summary tests passed as part of the 15-test
tooling run.
Review remediation copies blob-internal retry resources into the retained
diagnostics artifact, records the artifact name and run URL, and renders
per-test retry/failure links in the GitHub job summary.
@@ -0,0 +1,137 @@
---
id: "08-performance-experiments"
title: "Run concurrency and setup experiments"
status: completed
wave: 4
depends_on:
- "03-ci-manifest-lifecycle"
- "06-container-isolation"
plan: "plan.md"
spec: "../../specs/e2e-duration-aware-sharding/spec.md"
---
# Task 08: Run concurrency and setup experiments
## Acceptance
- A repeatable comparison records `workers: 1` versus `workers: 2` on the
known heavy normal shards, including wall time, CPU/memory pressure, backend
isolation failures, and retry counts; the default remains one worker unless
evidence supports a later change.
- A repeatable comparison measures the unified normal matrix against a
separately balanced mobile matrix, including project/setup overhead and
actual shard tail; no matrix split is enabled without the report.
- Build, pnpm install, runtime-image startup, and Playwright browser extraction
are measured separately, and any cache or pre-baked-image change is kept
behind evidence of lower wall time without a reliability regression.
## Verification
```sh
cd apps/web
E2E_SHARD=2 /usr/bin/time -v bash e2e/scripts/run-planned-shard.sh \
e2e/manifests/normal/2.json -- --workers=2
E2E_SHARD=2 /usr/bin/time -v bash e2e/scripts/run-planned-shard.sh \
e2e/manifests/normal/2.json -- --workers=1
```
Generate `e2e/manifests/normal/2.json` with the Task 03 planner first. Run the
paired commands on the same runner class and record results in the experiment
report. The runner forwards the worker override after `--`, so both commands
execute the exact same duration-aware file selection. Do not compare unrelated
ordinal assignments.
## Files likely touched
- `.github/workflows/e2e-tests.yml` (diagnostic/manual experiment hooks only)
- `apps/web/e2e/README.md` (experiment procedure and result format)
- CI image/cache configuration if a measured setup optimization is approved
## Dependencies
Depends on Task 03 for reproducible manifests and Task 06 for safe concurrent
container behavior.
## Parallelism
Sequential after the default manifest workflow is stable. Experiments must be
isolated from the blocking PR lane.
## Inputs
- Current workers-one invariant and worker-scoped backend fixture.
- Heavy normal and container shard timing evidence from the investigation.
- Build and setup timings from the PR #2471 workflow run.
## Output contract
Report paired measurements, runner/environment details, flake/resource
observations, recommendation, and explicit statement of which defaults remain
unchanged.
## Experiment report (2026-08-11)
The README contains the controlled experiment procedure and result format, and
the runner forwards worker overrides after the manifest separator. The local
experiment used the post-merge checkout at `d150554a5`, a rebuilt backend/web
artifact, and the heaviest predicted normal shard (`normal/10.json`, 44 files,
191 tests). The runner image does not provide GNU `/usr/bin/time`, so Bash
`time -p` supplied wall/user/system time and a process-group sampler supplied
peak RSS and CPU for the monitored runs.
### Environment
- Node `v24.16.0`; pnpm `9.15.9`.
- AMD Ryzen 5 7640HS, 6 cores / 12 threads; 10 online CPUs reported to the
process.
- 18 GiB RAM, 15 GiB available at measurement start, 8 GiB swap.
- Docker `26.1.5`, Linux `amd64`.
- Local build artifacts were rebuilt at the measured commit before E2E runs.
### CI comparison
The latest successful main baseline, [run 31423650879](https://github.com/kdlbs/kandev/actions/runs/31423650879), took 28m30s from workflow creation to completion. The post-conflict PR run, [run 31469837346](https://github.com/kdlbs/kandev/actions/runs/31469837346), took 22m39s and passed all 42 checks: a 5m51s (20.5%) full-workflow reduction.
The PR run's normal shard cohort ran from 07:45:35Z to the slowest job finish at 08:00:41Z (15m06s); the slowest normal job was 12m47s. The baseline's slowest normal job was 17m41s. The container tail improved from 9m26s to 5m14s. The PR retry summary recorded 2 tests that passed after one retry, 0 final failures, and 0 timeouts.
Both the PR run and the latest successful main run used the deterministic
count-fallback path: the latest main run, [31469080336](https://github.com/kdlbs/kandev/actions/runs/31469080336), has no `e2e-timing-profile` artifact. The first successful main run after this change lands will bootstrap the rolling profile; until then, the planner is implemented but the CI timing-profile path is not yet exercised.
### Worker experiment
| workers | repetitions | wall time | result | sampled peak RSS | decision |
| ---: | ---: | --- | --- | ---: | --- |
| 1 | 3 | 458.110s / 458.237s / 458.252s; median 458.237s; CV 0.014% | 191/191 passed, no retries | 874–948 MiB on 2 monitored runs | keep default |
| 2 | 1 | 268.715s | 189 passed; 2 flaky tests passed after retry; exit 1 with `E2E_FAIL_ON_FLAKY=1` | 1.32 GiB | reject |
The two-worker run failed the reliability gate with a passthrough-terminal
assertion and a `fetch failed` error. It is faster by 41.4%, but it does not
meet the zero-retry/zero-isolation-error requirement. The checked-in default
remains `workers: 1`; no candidate worker change was applied.
### Matrix, shard-shape, and setup measurements
- The current unified 14-shard profile simulation predicts a 611.1s maximum
shard. The best separate matrix allocation was 11 Chromium + 3 mobile
shards at 653.7s (+7.0%) before extra setup, backend, and report overhead.
The split was not executed because it failed the 10% improvement threshold.
- Normal shard simulation produced maxima of 855.3s at 10 shards, 611.1s at
14, and 534.8s at 16. Container maxima were 210.9s at 4, 154.8s at 6, and
remained 154.8s at 8 because `docker-launch.spec.ts` is the dominant
indivisible unit. Keep 14 normal and 6 container shards.
- On the CI runner, dependency install took 13s, backend/web build took
4m44s, and the complete Build job took 6m18s. Container setup extracted
Playwright browsers in 33s. Warm local measurements were pnpm install
0.93s, browser install verification 0.57s, and a local runtime-image
startup probe 0.45s. Cold cache misses and image pulls still need a runner
measurement before changing setup or image strategy.
### Decisions
- **Adopt now:** keep the generated-manifest workflow, 14/6 matrix, unified
normal/mobile projects, and `workers: 1`; retain retry and shard diagnostics.
- **Needs CI confirmation:** consume the first post-merge main timing profile,
measure cold setup/cache behavior, and repeat any future worker candidate on
the CI runner class.
- **Reject:** workers=2 for the current shared-backend fixture, and a separate
mobile matrix based on the profile simulation.
@@ -0,0 +1,73 @@
---
id: "09-rollout-documentation"
title: "Document rollout and operating checks"
status: completed
wave: 5
depends_on:
- "03-ci-manifest-lifecycle"
- "06-container-isolation"
- "07-retry-observability"
plan: "plan.md"
spec: "../../specs/e2e-duration-aware-sharding/spec.md"
---
# Task 09: Document rollout and operating checks
## Acceptance
- `apps/web/e2e/README.md` documents profile ownership, artifact names,
manifest generation, new/changed-test fallback behavior, local dry runs,
retry summaries, and container ownership requirements.
- Workflow comments and CI job summaries explain how to diagnose a stale or
unavailable profile, an invalid manifest, a passed-after-retry result, and a
shard predicted/actual mismatch.
- The rollout checklist records the three-successful-main-run balance check,
the no-hidden-flake check, and the default `workers: 1`/matrix decisions.
## Verification
```sh
cd apps/web
pnpm typecheck
cd ../..
git diff --check
```
Review all paths in the linked spec, plan, and ADR after the implementation
changes so command names, artifact names, and status claims match the actual
workflow.
## Files likely touched
- `apps/web/e2e/README.md`
- `.github/workflows/e2e-tests.yml`
- `docs/specs/e2e-duration-aware-sharding/spec.md` (status or contract sync)
- `docs/decisions/2026-08-10-duration-aware-e2e-sharding.md` (consequence or
implementation-status sync if needed)
## Dependencies
Depends on the final CI, container, and retry contracts from Tasks 03, 06, and 07. It should not invent a second source of truth for those contracts.
## Parallelism
Sequential finalization after behavior stabilizes.
## Inputs
- The generated workflow behavior and report examples.
- Existing E2E README commands and container prerequisites.
- The spec success criteria and proposed ADR.
## Output contract
Report documentation paths changed, command examples verified, rollout status,
and any remaining discrepancies between artifacts and implementation.
## Implementation result
Updated the E2E README, workflow comments/job summary, spec, plan, ADR, and
task statuses to describe the final artifact names, fallback behavior, scoped
Docker ownership, retry diagnostics, and rollout checks. The README commands
were reviewed against the generated runner and workflow paths; typecheck and
diff checks passed.
+1
View File
@@ -256,6 +256,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance,
| [browser-inspect-annotations-save](browser-inspect-annotations-save/spec.md) | shipped |
| [automations-pr-merged-trigger](automations-pr-merged-trigger/spec.md) | draft |
| [no-silent-model-fallback](no-silent-model-fallback/spec.md) | approved |
| [e2e-duration-aware-sharding](e2e-duration-aware-sharding/spec.md) | implemented |
| [board-step-visibility-filter](board-step-visibility-filter/spec.md) | draft |
---
@@ -0,0 +1,187 @@
---
status: implemented
created: 2026-08-10
---
# Duration-aware E2E sharding and CI reliability
## Why
The E2E workflow currently uses Playwright's ordinal test-level sharding. It
keeps test counts roughly even, but not execution time. In the investigated
run, the normal matrix had fourteen shards ranging from 3m52 to 17m57, while
the container matrix had six shards ranging from about 3m to 9m59. The slowest
shards therefore determine most of the wall time even when the other runners
are idle.
The same investigation found seven retry groups in the normal PR run. The
retry policy made the final run green, but several failures represented
unstable test data, leaked state, or missing synchronization rather than
transient infrastructure. Container tests also remove globally labelled
containers, so unrelated daemon state can affect their result.
This capability makes shard assignment adapt to the suite as it grows. It
also turns the known reliability findings into deterministic regression work
and makes passed-after-retry results visible.
## What
The E2E workflow gains two duration-aware cohorts:
- **Normal:** the existing fourteen-shard Chromium and mobile-Chrome matrix.
- **Containers:** the existing six-shard Docker and SSH matrix.
Each run creates an ephemeral manifest for each cohort. The manifest contains
the explicit test files assigned to each shard, the estimated cost, and the
profile source used for that estimate. Playwright runs those files directly;
the first implementation keeps `workers: 1` and the existing project
boundaries.
The estimates come from a rolling timing profile produced by successful runs
on `main`. A later `main` run merges its new successful samples into the
profile, so adding tests and changing test durations update future plans
without a hand-edited shard file.
## Timing profile contract
The profile is an artifact, not a checked-in source file. Its stable test key
is:
```text
<playwright project> + <repository-relative spec path> + <full test title>
```
Each entry records the source file hash, recent successful samples, a p50 and
p75 duration, the last source commit, and the last successful run. The
collector retains a bounded recent sample history per test. Only a successful
first attempt is a baseline sample. Failed, timed-out, and retry attempts are
excluded so a flake or an exceptional timeout does not permanently inflate the
plan.
The latest successful `main` profile is authoritative. Pull request runs may
publish diagnostic timing artifacts, but they do not replace the `main`
baseline. The planner applies these fallbacks in order:
1. Use the entry's p75 for an unchanged test.
2. Apply a conservative changed-file multiplier to a matching entry whose
source file hash differs from the profile.
3. Use a project/cohort fallback estimate for a new or unknown test.
4. If no profile can be downloaded, use a deterministic count-based plan.
Unknown, changed, and stale counts are reported with every plan. Profile
entries that are no longer discovered are ignored by planning and eventually
removed when the bounded history is compacted.
## Shard planner contract
The planner discovers the same Playwright test catalog used by the selected
cohort. It treats a project/file pair as the initial indivisible work unit,
sums the selected project costs for a file when projects share it, sorts units
by estimated duration descending, and assigns each unit to the currently
lightest shard. Ties use stable project/file ordering and the lowest shard
number. This makes a plan reproducible for the same catalog and profile.
Every manifest must pass these checks before it is uploaded:
- every discovered project/file unit appears exactly once;
- no manifest contains a unit outside the selected cohort;
- the declared shard count is exactly fourteen or six as appropriate;
- the sum of assigned estimates matches the per-shard totals;
- unknown and changed units are listed rather than silently omitted.
If a single file remains a material outlier after weighted file planning, a
later planner revision may split that file into `file:line` test selections.
That refinement is data-driven and does not change the initial manifest
contract.
## CI lifecycle
1. The build/planning job builds the existing artifacts and obtains the latest
successful `main` timing profile using the workflow token.
2. It discovers the catalog, creates normal and container manifests, validates
coverage, and uploads the manifests as a run-scoped artifact.
3. Each matrix job downloads only its cohort manifest and invokes a wrapper
with the explicit file selections. It preserves the existing blob reports,
project selection, retry count, timeout, and `workers: 1` behavior.
4. The report job merges the blob reports and emits the new timing profile,
retry summary, prediction-versus-actual summary, and plan health counters.
5. Only a successful `main` result is eligible to become the next baseline.
If the `main` artifact is unavailable, the workflow continues with the
validated count-based fallback and records that fallback in the report. A
missing or malformed manifest is a hard planning failure, not permission to
silently revert one matrix shard to ordinal sharding.
## Reliability contract
The following findings from PR #2471 become focused E2E repairs:
- scope the disabled-integrations link assertion to an exact, unique locator;
- derive multi-PR review expectations from the dynamically created repository
instead of a fixed repository name;
- clear or isolate restored task-create drafts before testing prompt
autocomplete;
- replace the fragile config-management marker wait with a state-aware
synchronization point;
- wait for mobile autopilot sidebar hydration/WS convergence before asserting
its state;
- make Docker storage-maintenance cleanup task-owned and use unique labels or
scoped counts instead of deleting or counting unrelated managed containers.
The desktop and mobile multi-PR failures share the same fixture defect and
must be fixed together. Each repaired failure gets a regression assertion that
fails for the observed cause, not only a larger timeout.
Playwright output also includes a retry summary containing the test key,
attempt count, final status, error context, and links to retry artifacts. The
normal PR lane may retain its current retry gate while the summary is being
rolled out. A scheduled or explicitly diagnostic lane runs with
`failOnFlakyTests: true` so passed-after-retry results can be treated as a
first-class reliability signal.
## Performance experiments
The default remains one worker per shard until measurements prove that higher
concurrency is safe for the worker-scoped backend fixture. A controlled
experiment measures `workers: 2` on the known heavy shards and records wall
time, CPU/memory pressure, and retries before considering a default change.
A second experiment compares the unified normal matrix with a separately
balanced mobile matrix. It must use the same timing profile and report setup
overhead before changing the default matrix. Build/startup work is measured as
a separate budget, including package installation, runtime image startup, and
Playwright browser extraction; caching or pre-baked images are adopted only if
they improve wall time without increasing flakes.
## Success criteria
- Three consecutive successful `main` runs show materially smaller shard-tail
skew than the current baseline. The initial target is no shard above 1.25x
the cohort median unless one indivisible test unit dominates it.
- A newly added test appears in the next generated catalog without editing a
shard assignment file.
- A changed test file is marked warm and receives the conservative estimate
until successful `main` samples replace it.
- Manifest validation proves complete, non-overlapping coverage for both
cohorts on every run.
- The seven observed retry groups either have deterministic fixes or remain
visible in the retry summary with actionable evidence.
- The timing profile can be unavailable without blocking a correct,
deterministic fallback plan.
## Out of scope
- changing product behavior or the worker-scoped backend isolation model;
- making `workers: 2` the default before the experiment passes;
- committing per-shard test lists that require manual maintenance;
- moving timing data to an external database;
- globally deleting Docker resources to make a test pass;
- masking flakes by increasing timeouts or retries.
## Implementation status
Implemented in the CI workflow, E2E tooling, scoped Docker fixtures, and
focused reliability tests. The generated manifests, rolling profile, retry
diagnostics, and fail-closed runner are covered by unit/type checks and local
targeted E2E runs. The three-successful-main-run balance target remains an
operational check after merge.