Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion cmd/e2e-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ import (
gitcmd "go.kenn.io/kit/git/cmd"
"go.kenn.io/middleman/internal/config"
"go.kenn.io/middleman/internal/db"
"go.kenn.io/middleman/internal/gitclone"
ghclient "go.kenn.io/middleman/internal/github"
"go.kenn.io/middleman/internal/platform"
"go.kenn.io/middleman/internal/server"
"go.kenn.io/middleman/internal/stacks"
"go.kenn.io/middleman/internal/testutil"
"go.kenn.io/middleman/internal/web"
"go.kenn.io/middleman/internal/workspace"
)

// defaultRoborevEndpoint is the address the e2e server points the
Expand Down Expand Up @@ -575,6 +577,7 @@ func run(
if err != nil {
return fmt.Errorf("setup diff repo: %w", err)
}
e2eWorktreeDir := filepath.Join(tmpDir, "worktrees")

repos := []config.Repo{
{Owner: "acme", Name: "widgets"},
Expand Down Expand Up @@ -888,9 +891,10 @@ func run(
database, syncer, diffRepo.Manager, assets, cfg, cfgPath,
server.ServerOptions{
Clones: diffRepo.Manager,
WorktreeDir: filepath.Join(tmpDir, "worktrees"),
WorktreeDir: e2eWorktreeDir,
},
)
defer cleanupE2EWorkspaces(database, diffRepo.Manager, e2eWorktreeDir, cfg.TmuxCommand())
rootHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost &&
r.URL.Path == "/__e2e/pr-workflow-approval/required" {
Expand Down Expand Up @@ -1350,3 +1354,36 @@ func patchFixturePRSHAs(fc *testutil.FixtureClient, owner, repo string, number i
}
fc.UpdatePullRequestSHAs(owner, repo, number, headSHA, baseSHA)
}

func cleanupE2EWorkspaces(
database *db.DB,
clones *gitclone.Manager,
worktreeDir string,
tmuxCmd []string,
) {
if database == nil || worktreeDir == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

manager := workspace.NewManager(database, worktreeDir)
manager.SetTmuxCommand(tmuxCmd)
if clones != nil {
manager.SetClones(clones)
}
workspaces, err := manager.ListSummaries(ctx)
if err != nil {
slog.Warn("e2e workspace cleanup list failed", "err", err)
return
}
for _, summary := range workspaces {
if _, err := manager.Delete(ctx, summary.ID, true, nil); err != nil {
slog.Warn(
"e2e workspace cleanup delete failed",
"workspace_id", summary.ID,
"err", err,
)
}
}
}
33 changes: 33 additions & 0 deletions context/ui-design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,39 @@ Intent:

Use it between resizable panes. Pass a specific accessible label such as `Resize Activity rail` or `Resize file tree`.

### TabbedPanelTree

Use `TabbedPanelTree` for VS Code-like panel workspaces: tab groups that can
reorder tabs, drag tabs into another group, split a group horizontally or
vertically, and resize split panes.

Intent:

- one shared interaction model for draggable, tabbed, splittable panel groups
- let callers provide arbitrary panel content, tab icons, and tab action buttons
- keep dedicated sidebar resizing on `SplitResizeHandle` instead of forcing
every two-pane layout into a tabbed workspace model

Use it when a surface needs multiple interchangeable panels or future panes
inside a draggable workspace. Do not use it for simple fixed sidebars,
single-purpose drawers, or file-tree/content splits where `SplitResizeHandle`
or a narrower layout primitive is enough.

Use neutral `tabbed-panel-*` DOM classes/selectors for tests and consumers.
Do not add workflow-specific aliases or compatibility selectors when moving
this primitive into new surfaces.

Pass the mutation callbacks that match the interactions you expose:
`onMoveTabBefore`/`onAppendTabToLeaf` for tab sorting and cross-group moves,
`onSplitTab` for edge drops, and `onRatioChange` for divider resizing. Omitted
callbacks make that interaction read-only instead of rendering a visual drop
target that cannot apply.

The current accessibility scope is labeled tab groups, focusable tabs and tab
actions, and labeled pointer resize handles. Keyboard tab reordering, keyboard
splitting, and keyboard resizing are not implemented here; extend
`TabbedPanelTree` first if a consumer needs those interactions.

### SelectDropdown

Use `SelectDropdown` for single-value selection controls in the UI.
Expand Down
15 changes: 15 additions & 0 deletions frontend/scripts/e2e-run-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const defaultProjectRuns = [["--project=chromium"], ["--project=firefox"], ["--project=webkit"]];

function includesProjectArg(args: string[]): boolean {
return args.some((arg) => arg === "--project" || arg.startsWith("--project="));
}

export function planE2ERuns(requestedArgs: string[]): string[][] {
if (requestedArgs.length === 0) {
return defaultProjectRuns;
}
if (includesProjectArg(requestedArgs)) {
return [requestedArgs];
}
return defaultProjectRuns.map((projectArgs) => [...projectArgs, ...requestedArgs]);
}
38 changes: 27 additions & 11 deletions frontend/scripts/run-e2e-to-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import { constants } from "node:fs";
import { mkdir, open, readFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";

const outputFile = process.env.MIDDLEMAN_E2E_OUTPUT_FILE ?? "test-results/e2e.log";
import { planE2ERuns } from "./e2e-run-plan";

const outputFile = process.env.MIDDLEMAN_E2E_OUTPUT_FILE ?? "../tmp/e2e.log";
const displayFile = isAbsolute(outputFile) ? outputFile : resolve(outputFile);
const playwrightArgs = ["test", "--config=playwright-e2e.config.ts", ...process.argv.slice(2)];
const basePlaywrightArgs = ["test", "--config=playwright-e2e.config.ts"];
const requestedArgs = process.argv.slice(2);
const runs = planE2ERuns(requestedArgs);

function timestamp(): string {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
Expand All @@ -15,21 +19,33 @@ await mkdir(dirname(outputFile), { recursive: true });

const logFile = await open(outputFile, constants.O_CREAT | constants.O_TRUNC | constants.O_WRONLY, 0o666);
await logFile.write(
`[${timestamp()}] bun run test:e2e\n` + `argv: ${JSON.stringify(["playwright", ...playwrightArgs])}\n\n`,
`[${timestamp()}] bun run test:e2e\n` +
runs.map((args) => `argv: ${JSON.stringify(["playwright", ...basePlaywrightArgs, ...args])}`).join("\n") +
"\n\n",
);

let status = 1;
try {
const child = spawn("playwright", playwrightArgs, {
stdio: ["ignore", logFile.fd, logFile.fd],
});

status = await new Promise<number>((resolve, reject) => {
child.on("error", reject);
child.on("close", (code) => resolve(code ?? 1));
});
status = 0;
for (const args of runs) {
const playwrightArgs = [...basePlaywrightArgs, ...args];
await logFile.write(`[${timestamp()}] playwright ${playwrightArgs.join(" ")}\n\n`);
const child = spawn("playwright", playwrightArgs, {
stdio: ["ignore", logFile.fd, logFile.fd],
});

const runStatus = await new Promise<number>((resolve, reject) => {
child.on("error", reject);
child.on("close", (code) => resolve(code ?? 1));
});
await logFile.write(`\n[${timestamp()}] exit ${runStatus}: playwright ${playwrightArgs.join(" ")}\n\n`);
if (runStatus !== 0) {
status = runStatus;
}
}
} catch (error) {
await logFile.write(`${error instanceof Error ? error.message : String(error)}\n`);
status = 1;
} finally {
await logFile.close();
}
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
--bg-inset: #ecedf2;
--border-default: #d8dae2;
--border-muted: #e4e6ec;
--chrome-border-width: 1px;
--chrome-active-accent-width: 2px;
--chrome-pane-divider-width: 3px;
--chrome-dock-resize-hit-size: 7px;
--chrome-dock-resize-hit-outset: 4px;
--chrome-dock-resize-stripe-offset: 2px;
--text-primary: #181b24;
--text-secondary: #555b6e;
--text-muted: #878ea0;
Expand Down
13 changes: 8 additions & 5 deletions frontend/src/lib/components/RepoTypeahead.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,17 @@
interface Props {
selected: string | undefined;
onchange: (repo: string | undefined) => void;
initialOpen?: boolean;
}

let { selected, onchange }: Props = $props();
let { selected, onchange, initialOpen = false }: Props = $props();

const stores = getStores();

onMount(() =>
registerCheatsheetEntries("repo-typeahead", [
onMount(() => {
if (initialOpen) open = true;

return registerCheatsheetEntries("repo-typeahead", [
{
id: "repo-typeahead.next",
label: "Next repo",
Expand All @@ -57,8 +60,8 @@
binding: { key: " " },
scope: "view-pulls",
},
]),
);
]);
});

let fetchedRepos = $state<Repo[]>([]);
let reposLoading = $state(false);
Expand Down
36 changes: 34 additions & 2 deletions frontend/src/lib/components/design-system/DesignSystemPage.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script lang="ts">
import { Chip } from "@middleman/ui";
import DesignSystemTabbedPanelDemo from "./DesignSystemTabbedPanelDemo.svelte";
import DesignSystemTypeaheadDemo from "./DesignSystemTypeaheadDemo.svelte";

type ChipSize = "sm" | "md";

Expand Down Expand Up @@ -31,11 +33,41 @@
<p class="eyebrow">Shared primitives</p>
<h1>Design system</h1>
<p class="intro">
Validation surface for shared chip geometry, tone variants, casing, and
interactive states.
Validation surface for shared primitives used across maintainer
workflows.
</p>
</header>

<section class="card" aria-labelledby="tabbed-panel-title">
<div class="section-header">
<div>
<p class="section-kicker">TabbedPanelTree</p>
<h2 id="tabbed-panel-title">Tabbed workspace panels</h2>
</div>
<p class="section-copy">
Neutral panel workspace with draggable tabs, split drops, and
resizable panes.
</p>
</div>

<DesignSystemTabbedPanelDemo />
</section>

<section class="card" aria-labelledby="typeahead-title">
<div class="section-header">
<div>
<p class="section-kicker">RepoTypeahead</p>
<h2 id="typeahead-title">Typeahead dropdown states</h2>
</div>
<p class="section-copy">
Repository picker states using the configured repo tree and shared
dropdown row treatment.
</p>
</div>

<DesignSystemTypeaheadDemo />
</section>

<section class="card" aria-labelledby="chip-variants-title">
<div class="section-header">
<div>
Expand Down
Loading
Loading