mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-09 15:52:14 +00:00
* fix(#3565): automated AI fix * fix: address Codex review on PR #3567 * fix: address Codex review on PR #3567 * fix: address Codex review on PR #3567 * fix: address Codex review on PR #3567 * fix(scripts): observe stacked sentinel again after returning to library * fix(scripts): reuse shared persisted string hook for cross-window view changes --------- Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com> Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
205 lines
9.2 KiB
TypeScript
205 lines
9.2 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
|
|
import {
|
|
buildScriptsSidePanelRows,
|
|
collectScriptsSidePanelPackagePaths,
|
|
} from "./ScriptsSidePanel.tsx";
|
|
import type { Snippet } from "../types";
|
|
|
|
const snippet = (overrides: Partial<Snippet>): Snippet => ({
|
|
id: overrides.id ?? "snippet",
|
|
label: overrides.label ?? "Snippet",
|
|
command: overrides.command ?? "echo ok",
|
|
package: overrides.package ?? "",
|
|
order: overrides.order,
|
|
});
|
|
|
|
const source = readFileSync(new URL("./ScriptsSidePanel.tsx", import.meta.url), "utf8");
|
|
|
|
test("scripts side panel rows keep manual snippet order inside a package", () => {
|
|
const rows = buildScriptsSidePanelRows({
|
|
snippets: [
|
|
snippet({ id: "alpha", label: "Alpha", package: "ops", order: 3000 }),
|
|
snippet({ id: "zulu", label: "Zulu", package: "ops", order: 1000 }),
|
|
snippet({ id: "beta", label: "Beta", package: "ops", order: 2000 }),
|
|
],
|
|
packages: ["ops"],
|
|
expandedPaths: new Set(["ops"]),
|
|
});
|
|
|
|
assert.deepEqual(
|
|
rows.filter((row) => row.type === "snippet").map((row) => row.id),
|
|
["zulu", "beta", "alpha"],
|
|
);
|
|
});
|
|
|
|
test("scripts side panel active tabs pair the accent background with its foreground", () => {
|
|
assert.equal(source.match(/bg-accent text-accent-foreground/g)?.length, 2);
|
|
});
|
|
|
|
test("scripts side panel offers run-on-all-tabs for every snippet, not only automation scripts", () => {
|
|
// Guard against re-introducing the isScriptSnippet gate on workspace run actions.
|
|
assert.doesNotMatch(
|
|
source,
|
|
/onRunParallel=\{isScriptSnippet\(item\.row\.snippet\) && onRunScriptOnWorkspace/,
|
|
);
|
|
assert.match(source, /onRunParallel=\{onRunScriptOnWorkspace/);
|
|
// Sequential remains script-only (mode is meaningful only for automation runs).
|
|
assert.match(source, /onRunSequential=\{isScript && onRunScriptOnWorkspace/);
|
|
assert.match(source, /scripts\.actions\.runOnAllTabs/);
|
|
assert.match(source, /snippets\.action\.newPackage/);
|
|
assert.match(source, /openPackageDialog/);
|
|
});
|
|
|
|
test("scripts side panel snippet context menu offers copy command without running", () => {
|
|
// Copy must sit between the run actions and Edit, write snippet.command to
|
|
// the clipboard, and confirm with a toast — it never executes the command.
|
|
assert.match(source, /onCopyCommand=\{\(\) => handleCopySnippetCommand\(item\.snippet\)\}/);
|
|
assert.match(source, /onCopyCommand=\{\(\) => handleCopySnippetCommand\(item\.row\.snippet\)\}/);
|
|
assert.match(source, /scripts\.actions\.copyCommand'/);
|
|
const menu = source.match(/\{onRunParallel \? \([\s\S]*?<\/ContextMenu>/);
|
|
assert.ok(menu, "snippet row context menu should exist");
|
|
assert.match(menu[0], /onCopyCommand[\s\S]*?onClick=\{onEdit\}/);
|
|
assert.match(menu[0], /<Copy className="[\s\S]*?\{copyCommandLabel\}/);
|
|
assert.match(source, /handleCopySnippetCommand[\s\S]*?snippet\.command[\s\S]*?toast\.success/);
|
|
});
|
|
|
|
test("scripts side panel exposes create actions as inline toolbar icons", () => {
|
|
assert.match(source, /snippets\.action\.newSnippet/);
|
|
assert.match(source, /snippets\.action\.newPackage/);
|
|
assert.match(source, /snippets\.action\.newScript/);
|
|
assert.match(source, /openPackageDialog/);
|
|
assert.match(source, /handleAddScript/);
|
|
assert.doesNotMatch(source, /ChevronDown/);
|
|
assert.doesNotMatch(source, /DropdownTrigger/);
|
|
});
|
|
|
|
test("collectScriptsSidePanelPackagePaths includes implied ancestors", () => {
|
|
assert.deepEqual(
|
|
collectScriptsSidePanelPackagePaths(["ops/linux"], [
|
|
snippet({ id: "a", package: "ops/linux/disk" }),
|
|
snippet({ id: "b", package: "" }),
|
|
]).sort(),
|
|
["ops", "ops/linux", "ops/linux/disk"],
|
|
);
|
|
});
|
|
|
|
test("scripts side panel search is icon-toggled and expands below the toolbar", () => {
|
|
assert.match(source, /searchExpanded/);
|
|
assert.match(source, /setSearchExpanded/);
|
|
assert.match(source, /max-h-0 opacity-0/);
|
|
assert.match(source, /snippets\.searchPlaceholder/);
|
|
});
|
|
|
|
test("scripts side panel shows delete in a selection bar only when snippets are selected", () => {
|
|
assert.match(source, /selectedSnippetIds\.size > 0/);
|
|
assert.match(source, /snippets\.selection\.selected[\s\S]*?deleteSelectedSnippets/);
|
|
assert.match(source, /snippets\.selection\.deleteSelected/);
|
|
// Trash must not sit between multi-select and add on the permanent icon row.
|
|
assert.doesNotMatch(
|
|
source,
|
|
/snippets\.action\.selectSnippets[\s\S]*?Trash2[\s\S]*?snippets\.action\.newSnippet/,
|
|
);
|
|
});
|
|
|
|
test("scripts side panel toolbar exposes expand and collapse actions", () => {
|
|
assert.match(source, /vault\.tree\.expandAll/);
|
|
assert.match(source, /vault\.tree\.collapseAll/);
|
|
assert.match(source, /expandAllGroups/);
|
|
assert.match(source, /collapseAllGroups/);
|
|
assert.match(source, /isMultiSelectMode/);
|
|
});
|
|
|
|
test("scripts side panel confirms bulk delete and routes it through the shared delete event", () => {
|
|
// Mass-delete must confirm first (SnippetsManager parity) and must not bypass
|
|
// AppSideEffects via onSnippetsChange filtering — that path skips host binding cleanup.
|
|
assert.match(source, /VaultDeleteConfirmDialog/);
|
|
assert.match(source, /snippets\.selection\.deleteConfirmTitle/);
|
|
assert.match(source, /snippets\.selection\.deleteConfirmDesc/);
|
|
assert.match(source, /detail:\s*\{\s*ids\s*\}/);
|
|
assert.doesNotMatch(
|
|
source,
|
|
/onSnippetsChange\(snippets\.filter\(\(snippet\) => !selectedSnippetIds\.has/,
|
|
);
|
|
});
|
|
|
|
test("scripts side panel defers bulk-delete confirm when a parent owns the dialog", () => {
|
|
// Compact TerminalToolbar nests this panel in a Popover; the portalled confirm
|
|
// must be owned outside that tree so focus cannot unmount the prompt.
|
|
assert.match(source, /onBulkDeleteRequest\?:/);
|
|
assert.match(source, /if\s*\(\s*onBulkDeleteRequest\s*\)\s*\{/);
|
|
assert.match(source, /onBulkDeleteRequest\(ids\)/);
|
|
assert.match(source, /!onBulkDeleteRequest/);
|
|
});
|
|
|
|
test("scripts side panel clears pending bulk delete when the panel hides", () => {
|
|
// Returning null while isVisible is false unmounts the confirm dialog; drop
|
|
// pending deletes so a later re-show does not resurrect a half-dismissed prompt.
|
|
assert.match(
|
|
source,
|
|
/if\s*\(\s*isVisible\s*\)\s*return/,
|
|
);
|
|
assert.match(source, /setIsPackageDialogOpen\(\s*false\s*\)/);
|
|
assert.match(source, /setRenamingPackagePath\(\s*''\s*\)/);
|
|
assert.match(source, /setNewPackageName\(\s*''\s*\)/);
|
|
});
|
|
|
|
test("scripts side panel package dialog traps focus and exposes dialog close contract", () => {
|
|
assert.match(source, /packageDialogRef/);
|
|
assert.match(source, /data-dialog-close="true"/);
|
|
assert.match(source, /isPackageDialogOpen/);
|
|
});
|
|
|
|
test("scripts side panel package rows wrap in a rename and delete context menu", () => {
|
|
assert.match(source, /const PackageRow = memo/);
|
|
assert.match(
|
|
source,
|
|
/data-pkg-path=\{row\.path\}[\s\S]*?<ContextMenu>[\s\S]*?<ContextMenuTrigger asChild>[\s\S]*?\{rowButton\}/,
|
|
);
|
|
assert.match(source, /common\.rename/);
|
|
assert.match(source, /vault\.deleteConfirm\.packageDesc/);
|
|
assert.match(source, /deleteSnippetPackage/);
|
|
assert.match(source, /renameSnippetPackage/);
|
|
assert.match(source, /SNIPPET_PACKAGE_PATH_CHANGE_EVENT/);
|
|
assert.match(source, /detail: \{ from: renamingPackagePath, to: result\.newPath \}/);
|
|
assert.match(source, /detail: \{ from: path, to: null \}/);
|
|
assert.match(source, /openRenamePackageDialog/);
|
|
assert.match(source, /requestDeletePackage/);
|
|
});
|
|
|
|
test("scripts side panel does not start a drag from a non-primary pointer", () => {
|
|
assert.match(source, /from '\.\/ui\/primaryOnlyDrag'/);
|
|
assert.match(source, /isNonPrimaryPointer/);
|
|
assert.match(source, /primaryOnlyDragHandlers/);
|
|
});
|
|
|
|
test("scripts side panel offers a stacked view toggle that persists the choice", () => {
|
|
assert.match(source, /useScriptsViewMode/);
|
|
const toggle = source.match(/viewStacked[\s\S]*?\{viewMode === 'stacked' \? <List size=\{14\} \/> : <LayoutGrid size=\{14\} \/>\}/);
|
|
assert.ok(toggle, "view toggle button should swap list / stacked icons");
|
|
assert.match(source, /aria-pressed=\{viewMode === 'stacked'\}/);
|
|
// The chosen mode persists via the shared hook so it survives app restarts.
|
|
assert.match(source, /useScriptsViewMode\(\)/);
|
|
assert.match(source, /setViewMode\(viewMode === 'stacked' \? 'list' : 'stacked'\)/);
|
|
});
|
|
|
|
test("scripts side panel stacked view renders wrap-around chips and keeps run behavior", () => {
|
|
const stacked = source.match(/viewMode === 'stacked' \? \([\s\S]*?\) : \(/);
|
|
assert.ok(stacked, "stacked branch should exist in the content area");
|
|
assert.match(stacked[0], /flex flex-wrap/);
|
|
// Chips reuse the same click handler (multi-select / run / execute) as rows.
|
|
assert.match(stacked[0], /onClick=\{\(\) => handleSnippetClick\(snippet\)\}/);
|
|
// Packages stay visible as section headers with their counts.
|
|
assert.match(stacked[0], /item\.row\.name/);
|
|
assert.match(stacked[0], /item\.countLabel/);
|
|
assert.match(source, /const SnippetChip = memo/);
|
|
// The stacked chip keeps the same context menu contract (edit / delete / run).
|
|
const chip = source.match(/const SnippetChip = memo[\s\S]*?SnippetChip\.displayName/);
|
|
assert.ok(chip, "SnippetChip component should exist");
|
|
assert.match(chip[0], /ContextMenuContent/);
|
|
assert.match(chip[0], /onRunParallel/);
|
|
assert.match(chip[0], /onEdit/);
|
|
});
|