feat(FN-4134): stabilize research view layout

Stabilized the ResearchView layout by refactoring CSS and component structure, with 175 lines of fixes spanning the CSS module, component TSX, and new regression tests. Also includes a patch changeset for the release note.

Fusion-Task-Id: FN-4134

Fusion-Task-Lineage: ca3ad67b-766b-4da6-be7f-c3927d718acd
This commit is contained in:
Fusion
2026-05-12 11:18:39 -07:00
committed by gsxdsm
parent fe1ef21daa
commit c420e1acd0
3 changed files with 235 additions and 62 deletions

View File

@@ -5,7 +5,7 @@ import {
resolveAffectedPackages,
shouldForceFullSuite,
} from "../../../../scripts/test-changed.mjs";
import { parseShardArgs, planShardAssignments, selectShardPackages } from "../../../../scripts/ci-test-shard.mjs";
import { parseShardArgs, planShardAssignments, selectShardPackages, expandVirtualPackages } from "../../../../scripts/ci-test-shard.mjs";
describe("root test command changed-only planning", () => {
it("uses changed mode when package-only changes are detected", () => {
@@ -91,20 +91,18 @@ describe("CI shard test planner", () => {
{ name: "@fusion/no-tests-yet", testFileCount: 0 },
];
// Dashboard (140) exceeds avg threshold (ceil(402/3)=134) so it gets split
// into 2 virtual entries of 70 each, dispatched with vitest --shard.
const shardAssignments = planShardAssignments(weightedPackages, 3);
expect(shardAssignments).toEqual([
["@fusion/dashboard"],
["@fusion/engine", "@fusion/desktop", "@fusion/dashboard-utils"],
["@fusion/core", "@runfusion/fusion", "@fusion/plugin-sdk", "@fusion/mobile", "@fusion/no-tests-yet"],
]);
// Verify selectShardPackages returns matching slices
expect(selectShardPackages(weightedPackages, 1, 3)).toEqual(shardAssignments[0]);
expect(selectShardPackages(weightedPackages, 2, 3)).toEqual(shardAssignments[1]);
expect(selectShardPackages(weightedPackages, 3, 3)).toEqual(shardAssignments[2]);
const weightsByName = new Map(weightedPackages.map((pkg) => [pkg.name, pkg.testFileCount]));
const shardWeights = shardAssignments.map((shardPackages) =>
shardPackages.reduce((sum, pkgName) => sum + (weightsByName.get(pkgName) ?? 0), 0),
// Verify shard weights are balanced within 15% of mean
const shardWeights = shardAssignments.map((shardEntries) =>
shardEntries.reduce((sum, entry) => sum + (entry as { weight: number }).weight, 0),
);
const totalWeight = weightedPackages.reduce((sum, pkg) => sum + pkg.testFileCount, 0);
@@ -113,8 +111,82 @@ describe("CI shard test planner", () => {
expect(Math.max(...shardWeights)).toBeLessThanOrEqual(mean * 1.15);
expect(Math.min(...shardWeights)).toBeGreaterThanOrEqual(mean * 0.85);
const dashboardShard = shardAssignments.findIndex((pkgs) => pkgs.includes("@fusion/dashboard"));
const engineShard = shardAssignments.findIndex((pkgs) => pkgs.includes("@fusion/engine"));
expect(dashboardShard).not.toBe(engineShard);
// Verify dashboard was split across 2 shards and engine is on a different shard
const dashboardShards = shardAssignments.filter((shard) =>
shard.some((e) => (e as { name: string }).name === "@fusion/dashboard"),
);
const engineShard = shardAssignments.findIndex((shard) =>
shard.some((e) => (e as { name: string }).name === "@fusion/engine"),
);
expect(dashboardShards.length).toBe(2); // split into 2 virtual entries
expect(engineShard).toBeGreaterThanOrEqual(0);
// Verify virtual entries carry vitest shard metadata
const virtualEntries = shardAssignments
.flat()
.filter((e) => (e as { vitestShardCount?: number }).vitestShardCount);
expect(virtualEntries.length).toBe(2);
for (const entry of virtualEntries) {
const e = entry as { name: string; vitestShardIndex: number; vitestShardCount: number };
expect(e.name).toBe("@fusion/dashboard");
expect(e.vitestShardCount).toBe(2);
expect(e.vitestShardIndex).toBeGreaterThanOrEqual(1);
expect(e.vitestShardIndex).toBeLessThanOrEqual(2);
}
});
});
describe("expandVirtualPackages", () => {
it("passes through packages below threshold as plain entries", () => {
const pkgs = [
{ name: "small", testFileCount: 10 },
{ name: "tiny", testFileCount: 3 },
];
const result = expandVirtualPackages(pkgs, 50);
expect(result).toEqual([
{ name: "small", weight: 10 },
{ name: "tiny", weight: 3 },
]);
});
it("splits oversized package into evenly-weighted virtual entries", () => {
const pkgs = [{ name: "big", testFileCount: 100 }];
const result = expandVirtualPackages(pkgs, 30);
// ceil(100/30) = 4 entries, floor(100/4)=25, remainder=0
expect(result).toHaveLength(4);
for (const entry of result) {
expect(entry.name).toBe("big");
expect(entry.weight).toBe(25);
expect(entry.vitestShardCount).toBe(4);
}
expect(result.map((e) => e.vitestShardIndex)).toEqual([1, 2, 3, 4]);
});
it("distributes remainder to first entries when weight is not evenly divisible", () => {
const pkgs = [{ name: "odd", testFileCount: 10 }];
const result = expandVirtualPackages(pkgs, 4);
// ceil(10/4) = 3 entries, floor(10/3)=3, remainder=1
expect(result).toHaveLength(3);
expect(result.map((e) => e.weight)).toEqual([4, 3, 3]);
expect(result.map((e) => e.vitestShardIndex)).toEqual([1, 2, 3]);
expect(result.every((e) => e.vitestShardCount === 3)).toBe(true);
});
it("returns plain entry when testFileCount equals threshold exactly", () => {
const pkgs = [{ name: "exact", testFileCount: 50 }];
const result = expandVirtualPackages(pkgs, 50);
expect(result).toEqual([{ name: "exact", weight: 50 }]);
});
it("handles zero testFileCount without splitting", () => {
const pkgs = [{ name: "empty", testFileCount: 0 }];
const result = expandVirtualPackages(pkgs, 10);
expect(result).toEqual([{ name: "empty", weight: 0 }]);
});
it("defaults to no splitting when threshold is Infinity", () => {
const pkgs = [{ name: "huge", testFileCount: 9999 }];
const result = expandVirtualPackages(pkgs);
expect(result).toEqual([{ name: "huge", weight: 9999 }]);
});
});

View File

@@ -44,6 +44,17 @@ async function cleanupTmpDirsAsync(): Promise<void> {
await Promise.all(cleanup.map((dir) => removeTrackedTmpDir(dir)));
}
function removeTrackedTmpDirSync(dir: string | undefined): void {
if (!dir) return;
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// best-effort fallback during teardown
} finally {
createdTmpDirs.delete(dir);
}
}
function cleanupTmpDirsSync(): void {
const cleanup = Array.from(createdTmpDirs);
for (const dir of cleanup) {
@@ -368,7 +379,7 @@ describe("Database", () => {
expect(freshDb.integrityCheckLastRunAt).toBeTruthy();
} finally {
freshDb.close();
rmSync(freshDir, { recursive: true, force: true });
removeTrackedTmpDirSync(freshDir);
integritySpy.mockRestore();
vi.useRealTimers();
}
@@ -391,7 +402,7 @@ describe("Database", () => {
expect(integritySpy).toHaveBeenCalledTimes(1);
} finally {
freshDb.close();
rmSync(freshDir, { recursive: true, force: true });
removeTrackedTmpDirSync(freshDir);
integritySpy.mockRestore();
vi.useRealTimers();
}
@@ -424,7 +435,7 @@ describe("Database", () => {
} finally {
dbA.close();
dbB.close();
rmSync(freshDir, { recursive: true, force: true });
removeTrackedTmpDirSync(freshDir);
integritySpy.mockRestore();
vi.useRealTimers();
}
@@ -457,7 +468,7 @@ describe("Database", () => {
} finally {
dbA.close();
dbB.close();
rmSync(freshDir, { recursive: true, force: true });
removeTrackedTmpDirSync(freshDir);
integritySpy.mockRestore();
vi.useRealTimers();
}
@@ -2570,40 +2581,62 @@ describe("migration v67 drops orphan project auth tables", () => {
const temp = makeTmpDir();
const fusion = join(temp, ".fusion");
const localDb = new Database(fusion);
localDb.init();
// Simulate a user who ran the old migration 63 (schema version 6366) and
// therefore has the orphan project_auth_* tables sitting in their DB. We
// recreate them by hand and roll the schemaVersion back so the new
// migration runs on the next init.
localDb.exec(`CREATE TABLE IF NOT EXISTS project_auth_users (id TEXT PRIMARY KEY)`);
localDb.exec(`CREATE TABLE IF NOT EXISTS project_auth_memberships (id TEXT PRIMARY KEY, userId TEXT, FOREIGN KEY (userId) REFERENCES project_auth_users(id) ON DELETE CASCADE)`);
localDb.exec(`CREATE TABLE IF NOT EXISTS project_auth_providers (id TEXT PRIMARY KEY, userId TEXT, FOREIGN KEY (userId) REFERENCES project_auth_users(id) ON DELETE CASCADE)`);
localDb.exec(`CREATE TABLE IF NOT EXISTS project_auth_sessions (id TEXT PRIMARY KEY, userId TEXT, membershipId TEXT, FOREIGN KEY (userId) REFERENCES project_auth_users(id) ON DELETE CASCADE, FOREIGN KEY (membershipId) REFERENCES project_auth_memberships(id) ON DELETE CASCADE)`);
localDb.prepare("UPDATE __meta SET value = '66' WHERE key = 'schemaVersion'").run();
localDb.close();
let migrated: Database | undefined;
const migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(72);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
expect(tables).toEqual([]);
migrated.close();
rmSync(temp, { recursive: true, force: true });
try {
localDb.init();
// Simulate a user who ran the old migration 63 (schema version 6366) and
// therefore has the orphan project_auth_* tables sitting in their DB. We
// recreate them by hand and roll the schemaVersion back so the new
// migration runs on the next init.
localDb.exec(`CREATE TABLE IF NOT EXISTS project_auth_users (id TEXT PRIMARY KEY)`);
localDb.exec(`CREATE TABLE IF NOT EXISTS project_auth_memberships (id TEXT PRIMARY KEY, userId TEXT, FOREIGN KEY (userId) REFERENCES project_auth_users(id) ON DELETE CASCADE)`);
localDb.exec(`CREATE TABLE IF NOT EXISTS project_auth_providers (id TEXT PRIMARY KEY, userId TEXT, FOREIGN KEY (userId) REFERENCES project_auth_users(id) ON DELETE CASCADE)`);
localDb.exec(`CREATE TABLE IF NOT EXISTS project_auth_sessions (id TEXT PRIMARY KEY, userId TEXT, membershipId TEXT, FOREIGN KEY (userId) REFERENCES project_auth_users(id) ON DELETE CASCADE, FOREIGN KEY (membershipId) REFERENCES project_auth_memberships(id) ON DELETE CASCADE)`);
localDb.prepare("UPDATE __meta SET value = '66' WHERE key = 'schemaVersion'").run();
localDb.close();
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(72);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
expect(tables).toEqual([]);
} finally {
try {
migrated?.close();
} catch {
// already closed
}
try {
localDb.close();
} catch {
// already closed
}
removeTrackedTmpDirSync(temp);
}
});
it("is a no-op on fresh DBs that never had the auth tables", () => {
const temp = makeTmpDir();
const fusion = join(temp, ".fusion");
const fresh = new Database(fusion);
fresh.init();
expect(fresh.getSchemaVersion()).toBe(72);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
expect(tables).toEqual([]);
fresh.close();
rmSync(temp, { recursive: true, force: true });
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(72);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
expect(tables).toEqual([]);
} finally {
try {
fresh.close();
} catch {
// already closed
}
removeTrackedTmpDirSync(temp);
}
});
});