fix(gsd): detect initialized health widget projects (#1432)
* fix(gsd extension): detect initialized projects in health widget Use .gsd presence plus project-state detection for the health widget so bootstrapped projects no longer appear as unloaded before metrics exist. * fix(gsd extension): make health widget execution-aware Lead the health widget with current GSD execution state so it explains what the project is doing before surfacing provider and environment diagnostics. Keep issue, budget, and progress details as secondary context and cover the new output with focused widget tests. * fix(gsd extension): address review feedback on health widget PR - Replace em dash with ASCII hyphen in headline for terminal safety - Reformat catch/finally to standard single-line style - Replace computeProgressScore() status with direct phase labels so the status reflects the actual execution phase, not a global health aggregate - Use lightweight milestone-dir scan instead of full detectProjectState() to avoid unnecessary filesystem work on the 60s refresh - Add cache warm-up comment on updateSliceProgressCache call - Add safety comment on early void refresh() call - Update test assertions for new phase labels and ASCII separator
This commit is contained in:
parent
96b94065ff
commit
5eed57f876
3 changed files with 392 additions and 61 deletions
129
src/resources/extensions/gsd/health-widget-core.ts
Normal file
129
src/resources/extensions/gsd/health-widget-core.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* Pure GSD health widget logic.
|
||||
*
|
||||
* Separates project-state detection and line rendering from the widget's
|
||||
* runtime integrations so the regressions can be tested directly.
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { gsdRoot } from "./paths.js";
|
||||
import { join } from "node:path";
|
||||
import type { GSDState, Phase } from "./types.js";
|
||||
|
||||
export type HealthWidgetProjectState = "none" | "initialized" | "active";
|
||||
|
||||
export interface HealthWidgetData {
|
||||
projectState: HealthWidgetProjectState;
|
||||
budgetCeiling: number | undefined;
|
||||
budgetSpent: number;
|
||||
providerIssue: string | null;
|
||||
environmentErrorCount: number;
|
||||
environmentWarningCount: number;
|
||||
lastRefreshed: number;
|
||||
executionPhase?: Phase;
|
||||
executionStatus?: string;
|
||||
executionTarget?: string;
|
||||
nextAction?: string;
|
||||
blocker?: string | null;
|
||||
activeMilestoneId?: string;
|
||||
activeSliceId?: string;
|
||||
activeTaskId?: string;
|
||||
progress?: GSDState["progress"];
|
||||
eta?: string | null;
|
||||
}
|
||||
|
||||
export function detectHealthWidgetProjectState(basePath: string): HealthWidgetProjectState {
|
||||
const root = gsdRoot(basePath);
|
||||
if (!existsSync(root)) return "none";
|
||||
|
||||
// Lightweight milestone count — avoids the full detectProjectState() scan
|
||||
// (CI markers, Makefile targets, etc.) that is unnecessary on the 60s refresh.
|
||||
try {
|
||||
const milestonesDir = join(root, "milestones");
|
||||
if (existsSync(milestonesDir)) {
|
||||
const entries = readdirSync(milestonesDir, { withFileTypes: true });
|
||||
if (entries.some(e => e.isDirectory())) return "active";
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
|
||||
return "initialized";
|
||||
}
|
||||
|
||||
function formatCost(n: number): string {
|
||||
return n >= 1 ? `$${n.toFixed(2)}` : `${(n * 100).toFixed(1)}¢`;
|
||||
}
|
||||
|
||||
function formatProgress(progress?: GSDState["progress"]): string | null {
|
||||
if (!progress) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`M ${progress.milestones.done}/${progress.milestones.total}`);
|
||||
if (progress.slices) parts.push(`S ${progress.slices.done}/${progress.slices.total}`);
|
||||
if (progress.tasks) parts.push(`T ${progress.tasks.done}/${progress.tasks.total}`);
|
||||
return parts.length > 0 ? `Progress: ${parts.join(" · ")}` : null;
|
||||
}
|
||||
|
||||
function formatEnvironmentSummary(errorCount: number, warningCount: number): string | null {
|
||||
if (errorCount <= 0 && warningCount <= 0) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
|
||||
if (warningCount > 0) parts.push(`${warningCount} warning${warningCount > 1 ? "s" : ""}`);
|
||||
return `Env: ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
function formatBudgetSummary(data: HealthWidgetData): string | null {
|
||||
if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) {
|
||||
const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100);
|
||||
return `Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`;
|
||||
}
|
||||
if (data.budgetSpent > 0) {
|
||||
return `Spent: ${formatCost(data.budgetSpent)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildExecutionHeadline(data: HealthWidgetData): string {
|
||||
const status = data.executionStatus ?? "Active project";
|
||||
const target = data.executionTarget ?? data.blocker ?? "loading status…";
|
||||
return ` GSD ${status}${target ? ` - ${target}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build compact health lines for the widget.
|
||||
* Returns a string array suitable for setWidget().
|
||||
*/
|
||||
export function buildHealthLines(data: HealthWidgetData): string[] {
|
||||
if (data.projectState === "none") {
|
||||
return [" GSD No project loaded — run /gsd to start"];
|
||||
}
|
||||
|
||||
if (data.projectState === "initialized") {
|
||||
return [" GSD Project initialized — run /gsd to continue setup"];
|
||||
}
|
||||
|
||||
const lines = [buildExecutionHeadline(data)];
|
||||
const details: string[] = [];
|
||||
|
||||
const progress = formatProgress(data.progress);
|
||||
if (progress) details.push(progress);
|
||||
|
||||
if (data.providerIssue) details.push(data.providerIssue);
|
||||
|
||||
const environment = formatEnvironmentSummary(
|
||||
data.environmentErrorCount,
|
||||
data.environmentWarningCount,
|
||||
);
|
||||
if (environment) details.push(environment);
|
||||
|
||||
const budget = formatBudgetSummary(data);
|
||||
if (budget) details.push(budget);
|
||||
|
||||
if (data.eta) details.push(data.eta);
|
||||
|
||||
if (details.length > 0) {
|
||||
lines.push(` ${details.join(" │ ")}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
|
@ -9,41 +9,37 @@
|
|||
*/
|
||||
|
||||
import type { ExtensionContext } from "@gsd/pi-coding-agent";
|
||||
import type { GSDState } from "./types.js";
|
||||
import { runProviderChecks, summariseProviderIssues } from "./doctor-providers.js";
|
||||
import { runEnvironmentChecks } from "./doctor-environment.js";
|
||||
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
||||
import { loadLedgerFromDisk, getProjectTotals } from "./metrics.js";
|
||||
import { describeNextUnit, estimateTimeRemaining, updateSliceProgressCache } from "./auto-dashboard.js";
|
||||
import { projectRoot } from "./commands.js";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface HealthWidgetData {
|
||||
hasProject: boolean;
|
||||
budgetCeiling: number | undefined;
|
||||
budgetSpent: number;
|
||||
providerIssue: string | null; // compact summary from summariseProviderIssues()
|
||||
environmentErrorCount: number;
|
||||
environmentWarningCount: number;
|
||||
lastRefreshed: number;
|
||||
}
|
||||
import { deriveState, invalidateStateCache } from "./state.js";
|
||||
import {
|
||||
buildHealthLines,
|
||||
detectHealthWidgetProjectState,
|
||||
type HealthWidgetData,
|
||||
} from "./health-widget-core.js";
|
||||
|
||||
// ── Data loader ────────────────────────────────────────────────────────────────
|
||||
|
||||
function loadHealthWidgetData(basePath: string): HealthWidgetData {
|
||||
let hasProject = false;
|
||||
function loadBaseHealthWidgetData(basePath: string): HealthWidgetData {
|
||||
let budgetCeiling: number | undefined;
|
||||
let budgetSpent = 0;
|
||||
let providerIssue: string | null = null;
|
||||
let environmentErrorCount = 0;
|
||||
let environmentWarningCount = 0;
|
||||
|
||||
const projectState = detectHealthWidgetProjectState(basePath);
|
||||
|
||||
try {
|
||||
const prefs = loadEffectiveGSDPreferences();
|
||||
budgetCeiling = prefs?.preferences?.budget_ceiling;
|
||||
|
||||
const ledger = loadLedgerFromDisk(basePath);
|
||||
if (ledger) {
|
||||
hasProject = true;
|
||||
const totals = getProjectTotals(ledger.units ?? []);
|
||||
budgetSpent = totals.cost;
|
||||
}
|
||||
|
|
@ -63,7 +59,7 @@ function loadHealthWidgetData(basePath: string): HealthWidgetData {
|
|||
} catch { /* non-fatal */ }
|
||||
|
||||
return {
|
||||
hasProject,
|
||||
projectState,
|
||||
budgetCeiling,
|
||||
budgetSpent,
|
||||
providerIssue,
|
||||
|
|
@ -73,54 +69,88 @@ function loadHealthWidgetData(basePath: string): HealthWidgetData {
|
|||
};
|
||||
}
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatCost(n: number): string {
|
||||
return n >= 1 ? `$${n.toFixed(2)}` : `${(n * 100).toFixed(1)}¢`;
|
||||
function compactText(text: string, max = 64): string {
|
||||
const trimmed = text.replace(/\s+/g, " ").trim();
|
||||
if (trimmed.length <= max) return trimmed;
|
||||
return `${trimmed.slice(0, max - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build compact health lines for the widget.
|
||||
* Returns a string array suitable for setWidget().
|
||||
*/
|
||||
export function buildHealthLines(data: HealthWidgetData): string[] {
|
||||
if (!data.hasProject) {
|
||||
return [" GSD No project loaded — run /gsd to start"];
|
||||
function summarizeExecutionStatus(state: GSDState): string {
|
||||
switch (state.phase) {
|
||||
case "blocked": return "Blocked";
|
||||
case "paused": return "Paused";
|
||||
case "complete": return "Complete";
|
||||
case "executing": return "Executing";
|
||||
case "planning": return "Planning";
|
||||
case "pre-planning": return "Pre-planning";
|
||||
case "summarizing": return "Summarizing";
|
||||
case "validating-milestone": return "Validating";
|
||||
case "completing-milestone": return "Completing";
|
||||
case "needs-discussion": return "Needs discussion";
|
||||
case "replanning-slice": return "Replanning";
|
||||
default: return "Active";
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// System status signal
|
||||
const totalIssues = data.environmentErrorCount + data.environmentWarningCount + (data.providerIssue ? 1 : 0);
|
||||
if (totalIssues === 0) {
|
||||
parts.push("● System OK");
|
||||
} else if (data.environmentErrorCount > 0 || data.providerIssue?.includes("✗")) {
|
||||
parts.push(`✗ ${totalIssues} issue${totalIssues > 1 ? "s" : ""}`);
|
||||
} else {
|
||||
parts.push(`⚠ ${totalIssues} warning${totalIssues > 1 ? "s" : ""}`);
|
||||
function summarizeExecutionTarget(state: GSDState): string {
|
||||
switch (state.phase) {
|
||||
case "needs-discussion":
|
||||
return state.activeMilestone ? `Discuss ${state.activeMilestone.id}` : "Discuss milestone draft";
|
||||
case "pre-planning":
|
||||
return state.activeMilestone ? `Plan ${state.activeMilestone.id}` : "Research & plan milestone";
|
||||
case "planning":
|
||||
return state.activeSlice ? `Plan ${state.activeSlice.id}` : "Plan next slice";
|
||||
case "executing":
|
||||
return state.activeTask ? `Execute ${state.activeTask.id}` : "Execute next task";
|
||||
case "summarizing":
|
||||
return state.activeSlice ? `Complete ${state.activeSlice.id}` : "Complete current slice";
|
||||
case "validating-milestone":
|
||||
return state.activeMilestone ? `Validate ${state.activeMilestone.id}` : "Validate milestone";
|
||||
case "completing-milestone":
|
||||
return state.activeMilestone ? `Complete ${state.activeMilestone.id}` : "Complete milestone";
|
||||
case "replanning-slice":
|
||||
return state.activeSlice ? `Replan ${state.activeSlice.id}` : "Replan current slice";
|
||||
case "blocked":
|
||||
return `waiting on ${compactText(state.blockers[0] ?? state.nextAction, 56)}`;
|
||||
case "paused":
|
||||
return compactText(state.nextAction || "waiting to resume", 56);
|
||||
case "complete":
|
||||
return "All milestones complete";
|
||||
default:
|
||||
return compactText(describeNextUnit(state).label, 56);
|
||||
}
|
||||
}
|
||||
|
||||
// Budget
|
||||
if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) {
|
||||
const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100);
|
||||
parts.push(`Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`);
|
||||
} else if (data.budgetSpent > 0) {
|
||||
parts.push(`Spent: ${formatCost(data.budgetSpent)}`);
|
||||
async function enrichHealthWidgetData(basePath: string, baseData: HealthWidgetData): Promise<HealthWidgetData> {
|
||||
if (baseData.projectState !== "active") return baseData;
|
||||
|
||||
try {
|
||||
invalidateStateCache();
|
||||
const state = await deriveState(basePath);
|
||||
|
||||
if (state.activeMilestone) {
|
||||
// Warm the slice-progress cache so estimateTimeRemaining() has data
|
||||
updateSliceProgressCache(basePath, state.activeMilestone.id, state.activeSlice?.id);
|
||||
}
|
||||
|
||||
return {
|
||||
...baseData,
|
||||
executionPhase: state.phase,
|
||||
executionStatus: summarizeExecutionStatus(state),
|
||||
executionTarget: summarizeExecutionTarget(state),
|
||||
nextAction: state.nextAction,
|
||||
blocker: state.blockers[0] ?? null,
|
||||
activeMilestoneId: state.activeMilestone?.id,
|
||||
activeSliceId: state.activeSlice?.id,
|
||||
activeTaskId: state.activeTask?.id,
|
||||
progress: state.progress,
|
||||
eta: state.phase === "blocked" || state.phase === "paused" || state.phase === "complete"
|
||||
? null
|
||||
: estimateTimeRemaining(),
|
||||
};
|
||||
} catch {
|
||||
return baseData;
|
||||
}
|
||||
|
||||
// Provider issue (if any)
|
||||
if (data.providerIssue) {
|
||||
parts.push(data.providerIssue);
|
||||
}
|
||||
|
||||
// Environment issues
|
||||
if (data.environmentErrorCount > 0) {
|
||||
parts.push(`Env: ${data.environmentErrorCount} error${data.environmentErrorCount > 1 ? "s" : ""}`);
|
||||
} else if (data.environmentWarningCount > 0) {
|
||||
parts.push(`Env: ${data.environmentWarningCount} warning${data.environmentWarningCount > 1 ? "s" : ""}`);
|
||||
}
|
||||
|
||||
return [` ${parts.join(" │ ")}`];
|
||||
}
|
||||
|
||||
// ── Widget init ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -137,20 +167,34 @@ export function initHealthWidget(ctx: ExtensionContext): void {
|
|||
const basePath = projectRoot();
|
||||
|
||||
// String-array fallback — used in RPC mode (factory is a no-op there)
|
||||
const initialData = loadHealthWidgetData(basePath);
|
||||
const initialData = loadBaseHealthWidgetData(basePath);
|
||||
ctx.ui.setWidget("gsd-health", buildHealthLines(initialData), { placement: "belowEditor" });
|
||||
|
||||
// Factory-based widget for TUI mode — replaces the string-array above
|
||||
ctx.ui.setWidget("gsd-health", (_tui, _theme) => {
|
||||
let data = initialData;
|
||||
let cachedLines: string[] | undefined;
|
||||
let refreshInFlight = false;
|
||||
|
||||
const refreshTimer = setInterval(() => {
|
||||
const refresh = async () => {
|
||||
if (refreshInFlight) return;
|
||||
refreshInFlight = true;
|
||||
try {
|
||||
data = loadHealthWidgetData(basePath);
|
||||
const baseData = loadBaseHealthWidgetData(basePath);
|
||||
data = await enrichHealthWidgetData(basePath, baseData);
|
||||
cachedLines = undefined;
|
||||
_tui.requestRender();
|
||||
} catch { /* non-fatal */ }
|
||||
} catch { /* non-fatal */ } finally {
|
||||
refreshInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Fire first enrichment immediately. requestRender() inside is a no-op
|
||||
// if the widget has not yet rendered, so this is safe before factory return.
|
||||
void refresh();
|
||||
|
||||
const refreshTimer = setInterval(() => {
|
||||
void refresh();
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
|
||||
return {
|
||||
|
|
|
|||
158
src/resources/extensions/gsd/tests/health-widget.test.ts
Normal file
158
src/resources/extensions/gsd/tests/health-widget.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
buildHealthLines,
|
||||
detectHealthWidgetProjectState,
|
||||
type HealthWidgetData,
|
||||
} from "../health-widget-core.ts";
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
const dir = join(
|
||||
tmpdir(),
|
||||
`gsd-health-widget-test-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function cleanup(dir: string): void {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
function activeData(overrides: Partial<HealthWidgetData> = {}): HealthWidgetData {
|
||||
return {
|
||||
projectState: "active",
|
||||
budgetCeiling: undefined,
|
||||
budgetSpent: 0,
|
||||
providerIssue: null,
|
||||
environmentErrorCount: 0,
|
||||
environmentWarningCount: 0,
|
||||
lastRefreshed: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("detectHealthWidgetProjectState: no .gsd returns none", () => {
|
||||
const dir = makeTempDir("none");
|
||||
try {
|
||||
assert.equal(detectHealthWidgetProjectState(dir), "none");
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test("detectHealthWidgetProjectState: bootstrapped .gsd without milestones returns initialized", () => {
|
||||
const dir = makeTempDir("initialized");
|
||||
try {
|
||||
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
||||
assert.equal(detectHealthWidgetProjectState(dir), "initialized");
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test("detectHealthWidgetProjectState: milestone without metrics returns active", () => {
|
||||
const dir = makeTempDir("active");
|
||||
try {
|
||||
mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true });
|
||||
assert.equal(detectHealthWidgetProjectState(dir), "active");
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildHealthLines: none state shows onboarding copy", () => {
|
||||
assert.deepEqual(buildHealthLines(activeData({ projectState: "none" })), [
|
||||
" GSD No project loaded — run /gsd to start",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildHealthLines: initialized state shows continue setup copy", () => {
|
||||
assert.deepEqual(buildHealthLines(activeData({ projectState: "initialized" })), [
|
||||
" GSD Project initialized — run /gsd to continue setup",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildHealthLines: active state leads with execution summary", () => {
|
||||
const lines = buildHealthLines(activeData({
|
||||
executionStatus: "Executing",
|
||||
executionTarget: "Plan S01",
|
||||
progress: {
|
||||
milestones: { done: 0, total: 1 },
|
||||
slices: { done: 0, total: 3 },
|
||||
tasks: { done: 0, total: 5 },
|
||||
},
|
||||
}));
|
||||
|
||||
assert.equal(lines.length, 2);
|
||||
assert.equal(lines[0], " GSD Executing - Plan S01");
|
||||
assert.match(lines[1]!, /Progress: M 0\/1 · S 0\/3 · T 0\/5/);
|
||||
});
|
||||
|
||||
test("buildHealthLines: active state keeps issues secondary", () => {
|
||||
const lines = buildHealthLines(activeData({
|
||||
executionStatus: "Planning",
|
||||
executionTarget: "Execute T03",
|
||||
providerIssue: "✗ Anthropic (Claude) key missing",
|
||||
environmentWarningCount: 1,
|
||||
budgetSpent: 0.42,
|
||||
}));
|
||||
|
||||
assert.equal(lines.length, 2);
|
||||
assert.equal(lines[0], " GSD Planning - Execute T03");
|
||||
assert.match(lines[1]!, /✗ Anthropic \(Claude\) key missing/);
|
||||
assert.match(lines[1]!, /Env: 1 warning/);
|
||||
assert.match(lines[1]!, /Spent: 42\.0¢/);
|
||||
});
|
||||
|
||||
test("buildHealthLines: blocked state explains wait reason", () => {
|
||||
const lines = buildHealthLines(activeData({
|
||||
executionStatus: "Blocked",
|
||||
executionTarget: "waiting on unmet deps: M001",
|
||||
blocker: "M002 is waiting on unmet deps: M001",
|
||||
}));
|
||||
|
||||
assert.equal(lines[0], " GSD Blocked - waiting on unmet deps: M001");
|
||||
});
|
||||
|
||||
test("buildHealthLines: paused state can omit secondary line", () => {
|
||||
const lines = buildHealthLines(activeData({
|
||||
executionStatus: "Paused",
|
||||
executionTarget: "waiting to resume",
|
||||
}));
|
||||
|
||||
assert.deepEqual(lines, [" GSD Paused - waiting to resume"]);
|
||||
});
|
||||
|
||||
test("buildHealthLines: active state with budget ceiling shows percent summary", () => {
|
||||
const lines = buildHealthLines(activeData({
|
||||
executionStatus: "Executing",
|
||||
executionTarget: "Plan S01",
|
||||
budgetSpent: 2.5,
|
||||
budgetCeiling: 10,
|
||||
}));
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[1]!, /Budget: \$2\.50\/\$10\.00 \(25%\)/);
|
||||
});
|
||||
|
||||
test("detectHealthWidgetProjectState: metrics file alone does not imply project", () => {
|
||||
const dir = makeTempDir("metrics-only");
|
||||
try {
|
||||
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, ".gsd", "metrics.json"),
|
||||
JSON.stringify({ version: 1, projectStartedAt: Date.now(), units: [] }),
|
||||
"utf-8",
|
||||
);
|
||||
assert.equal(detectHealthWidgetProjectState(dir), "initialized");
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue