mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
feat: plugins only see their own host settings in the browser
This commit is contained in:
1 parent
50d43e29fb
commit
fb9c168b6b
17 files changed
+488
-52
No files matched your search
@@ -63,7 +63,10 @@ export interface PluginHostRecord {
|
|||||||
statusCheckInterval?: number | null;
|
statusCheckInterval?: number | null;
|
||||||
/** Keepalive, legacy algorithms, agent and environment options. */
|
/** Keepalive, legacy algorithms, agent and environment options. */
|
||||||
sshOptions?: HostSshOptions | null;
|
sshOptions?: HostSshOptions | null;
|
||||||
/** Each enabled plugin's host settings, secrets redacted. */
|
/**
|
||||||
|
* This plugin's own host settings, under its id, secrets redacted. Another
|
||||||
|
* plugin's settings never appear here; ask that plugin through an action.
|
||||||
|
*/
|
||||||
pluginSettings?: Record<string, Record<string, unknown>>;
|
pluginSettings?: Record<string, Record<string, unknown>>;
|
||||||
/** Each declared protocol's login, secrets left out. */
|
/** Each declared protocol's login, secrets left out. */
|
||||||
protocolAuth?: Record<string, HostProtocolAuthSummary>;
|
protocolAuth?: Record<string, HostProtocolAuthSummary>;
|
||||||
@@ -819,6 +822,13 @@ export interface TermixApp extends TermixAppInfo {
|
|||||||
pointId: string,
|
pointId: string,
|
||||||
extension: ExtensionContribution,
|
extension: ExtensionContribution,
|
||||||
) => Disposer;
|
) => Disposer;
|
||||||
|
/**
|
||||||
|
* Every host the user can see, fetched now. Like every host record a plugin
|
||||||
|
* gets, `pluginSettings` holds only this plugin's own settings.
|
||||||
|
*/
|
||||||
|
listHosts: () => Promise<PluginHostRecord[]>;
|
||||||
|
/** A saved host from the shell's list, or undefined. No request is made. */
|
||||||
|
getHost: (hostId: string | number) => PluginHostRecord | undefined;
|
||||||
registerSettingsComponent: (
|
registerSettingsComponent: (
|
||||||
componentId: string,
|
componentId: string,
|
||||||
component: ComponentType<SettingsComponentProps>,
|
component: ComponentType<SettingsComponentProps>,
|
||||||
@@ -1068,8 +1078,11 @@ export interface PluginCoreApi {
|
|||||||
getClientPreference: (name: string) => string | undefined;
|
getClientPreference: (name: string) => string | undefined;
|
||||||
/** Saves a browser-side UI preference where getClientPreference reads it. */
|
/** Saves a browser-side UI preference where getClientPreference reads it. */
|
||||||
setClientPreference: (name: string, value: string) => void;
|
setClientPreference: (name: string, value: string) => void;
|
||||||
/** Every host the user can see, fetched now rather than from the shell's cache. */
|
/**
|
||||||
listHosts: () => Promise<PluginHostRecord[]>;
|
* Every host the user can see, fetched now rather than from the shell's
|
||||||
|
* cache, carrying only `pluginId`'s host settings.
|
||||||
|
*/
|
||||||
|
listHosts: (pluginId: string) => Promise<PluginHostRecord[]>;
|
||||||
/** The user's stored credentials, without their secrets. */
|
/** The user's stored credentials, without their secrets. */
|
||||||
listCredentials: () => Promise<PluginCredentialSummary[]>;
|
listCredentials: () => Promise<PluginCredentialSummary[]>;
|
||||||
}
|
}
|
||||||
@@ -1231,10 +1244,6 @@ export function setClientPreference(name: string, value: string): void {
|
|||||||
requireHost().core.setClientPreference(name, value);
|
requireHost().core.setClientPreference(name, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listHosts(): Promise<PluginHostRecord[]> {
|
|
||||||
return requireHost().core.listHosts();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listCredentials(): Promise<PluginCredentialSummary[]> {
|
export function listCredentials(): Promise<PluginCredentialSummary[]> {
|
||||||
return requireHost().core.listCredentials();
|
return requireHost().core.listCredentials();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,13 +42,12 @@ export function useConsoleLook(
|
|||||||
appTheme: string,
|
appTheme: string,
|
||||||
): ConsoleLook {
|
): ConsoleLook {
|
||||||
const [look, setLook] = useState<ConsoleLook>(FALLBACK_CONSOLE_LOOK);
|
const [look, setLook] = useState<ConsoleLook>(FALLBACK_CONSOLE_LOOK);
|
||||||
const hostKey = host
|
// The terminal's settings live with the terminal, so it resolves them from
|
||||||
? `${host.id}:${JSON.stringify(host.pluginSettings ?? {})}`
|
// the host id.
|
||||||
: "";
|
const hostId = host?.id;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
invokeAction("terminal.resolveTheme", { host, appTheme })
|
invokeAction("terminal.resolveTheme", { hostId, appTheme })
|
||||||
.then((resolved) => {
|
.then((resolved) => {
|
||||||
if (active) setLook((resolved as ConsoleLook) ?? FALLBACK_CONSOLE_LOOK);
|
if (active) setLook((resolved as ConsoleLook) ?? FALLBACK_CONSOLE_LOOK);
|
||||||
})
|
})
|
||||||
@@ -58,8 +57,7 @@ export function useConsoleLook(
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [hostId, appTheme]);
|
||||||
}, [hostKey, appTheme]);
|
|
||||||
|
|
||||||
return look;
|
return look;
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,10 @@ function app(): TermixApp {
|
|||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listHosts(): ReturnType<TermixApp["listHosts"]> {
|
||||||
|
return app().listHosts();
|
||||||
|
}
|
||||||
|
|
||||||
export function fileManagerApi(): FileManagerClient {
|
export function fileManagerApi(): FileManagerClient {
|
||||||
return app().api as unknown as FileManagerClient;
|
return app().api as unknown as FileManagerClient;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { listHosts } from "@termix/plugin-sdk/frontend";
|
import { listHosts } from "./client";
|
||||||
import type { SSHHost } from "../host-types";
|
import type { SSHHost } from "../host-types";
|
||||||
|
|
||||||
export async function getSSHHosts(): Promise<SSHHost[]> {
|
export async function getSSHHosts(): Promise<SSHHost[]> {
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@termix/plugin-sdk/ui";
|
} from "@termix/plugin-sdk/ui";
|
||||||
import { Select2 } from "@termix/plugin-sdk/ui";
|
import { Select2 } from "@termix/plugin-sdk/ui";
|
||||||
import { discoverProxmoxGuestsStream, importProxmoxHosts } from "./proxmox-api";
|
import {
|
||||||
import { listHosts, type PluginHostRecord } from "@termix/plugin-sdk/frontend";
|
discoverProxmoxGuestsStream,
|
||||||
|
importProxmoxHosts,
|
||||||
|
listHosts,
|
||||||
|
} from "./proxmox-api";
|
||||||
|
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
|
||||||
import type { ProxmoxGuest } from "./types";
|
import type { ProxmoxGuest } from "./types";
|
||||||
import { resolveProxmoxImportAuth } from "./proxmox-import-auth";
|
import { resolveProxmoxImportAuth } from "./proxmox-import-auth";
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ function app(): TermixApp {
|
|||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listHosts(): ReturnType<TermixApp["listHosts"]> {
|
||||||
|
return app().listHosts();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs a guest discovery over the server-sent event stream and reports
|
* Runs a guest discovery over the server-sent event stream and reports
|
||||||
* progress. Returns a function that stops listening.
|
* progress. Returns a function that stops listening.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ComponentType, Ref } from "react";
|
import { useEffect, useState, type ComponentType, type Ref } from "react";
|
||||||
import {
|
import {
|
||||||
Info,
|
Info,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
MousePointerClick,
|
MousePointerClick,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
invokeAction,
|
||||||
useTranslation,
|
useTranslation,
|
||||||
type HostEditorSectionProps,
|
type HostEditorSectionProps,
|
||||||
type PluginHostRecord,
|
type PluginHostRecord,
|
||||||
@@ -175,21 +176,42 @@ function ToolbarCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The Wake-on-LAN address a host keeps, the default for a session's wake packet. */
|
/**
|
||||||
function wakeOnLanMac(host: PluginHostRecord | undefined): string | null {
|
* The Wake-on-LAN address a host keeps, the default for a session's wake
|
||||||
const value = host?.pluginSettings?.["wake-on-lan"]?.macAddress;
|
* packet. Null while that plugin is off.
|
||||||
return typeof value === "string" && value ? value : null;
|
*/
|
||||||
|
function useWakeOnLanMac(hostId: string | undefined): string | null {
|
||||||
|
const [mac, setMac] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
if (!hostId) {
|
||||||
|
setMac(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
invokeAction("wakeOnLan.macAddress", hostId)
|
||||||
|
.then((value) => {
|
||||||
|
if (active) setMac(typeof value === "string" && value ? value : null);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) setMac(null);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [hostId]);
|
||||||
|
return mac;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RdpSection(props: HostEditorSectionProps) {
|
function RdpSection(props: HostEditorSectionProps) {
|
||||||
const { form, setField, setGuacField } = remoteDesktopForm(props);
|
const { form, setField, setGuacField } = remoteDesktopForm(props);
|
||||||
|
const macAddress = useWakeOnLanMac(props.host?.id);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HostEditorRdpTab
|
<HostEditorRdpTab
|
||||||
form={form}
|
form={form}
|
||||||
setField={setField}
|
setField={setField}
|
||||||
setGuacField={setGuacField}
|
setGuacField={setGuacField}
|
||||||
host={{ macAddress: wakeOnLanMac(props.host) }}
|
host={{ macAddress }}
|
||||||
credentials={props.credentials as never}
|
credentials={props.credentials as never}
|
||||||
/>
|
/>
|
||||||
<ToolbarCard form={form} setField={setField} />
|
<ToolbarCard form={form} setField={setField} />
|
||||||
@@ -200,13 +222,14 @@ function RdpSection(props: HostEditorSectionProps) {
|
|||||||
|
|
||||||
function VncSection(props: HostEditorSectionProps) {
|
function VncSection(props: HostEditorSectionProps) {
|
||||||
const { form, setField, setGuacField } = remoteDesktopForm(props);
|
const { form, setField, setGuacField } = remoteDesktopForm(props);
|
||||||
|
const macAddress = useWakeOnLanMac(props.host?.id);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HostEditorVncTab
|
<HostEditorVncTab
|
||||||
form={form}
|
form={form}
|
||||||
setField={setField}
|
setField={setField}
|
||||||
setGuacField={setGuacField}
|
setGuacField={setGuacField}
|
||||||
host={{ macAddress: wakeOnLanMac(props.host) }}
|
host={{ macAddress }}
|
||||||
credentials={props.credentials as never}
|
credentials={props.credentials as never}
|
||||||
/>
|
/>
|
||||||
<ToolbarCard form={form} setField={setField} />
|
<ToolbarCard form={form} setField={setField} />
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
Terminal,
|
Terminal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { listHosts } from "@termix/plugin-sdk/frontend";
|
|
||||||
import type {
|
import type {
|
||||||
PanelProps,
|
PanelProps,
|
||||||
PluginHostRecord,
|
PluginHostRecord,
|
||||||
@@ -309,9 +308,11 @@ export function activate(app: TermixApp): void {
|
|||||||
);
|
);
|
||||||
let host: PluginHostRecord | null | undefined = request.host;
|
let host: PluginHostRecord | null | undefined = request.host;
|
||||||
if (!host && request.hostId !== undefined) {
|
if (!host && request.hostId !== undefined) {
|
||||||
host = (await listHosts().catch(() => [])).find(
|
host =
|
||||||
(entry) => String(entry.id) === String(request.hostId),
|
app.getHost(request.hostId) ??
|
||||||
);
|
(await app.listHosts().catch(() => [])).find(
|
||||||
|
(entry) => String(entry.id) === String(request.hostId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return resolveTerminalLook({ ...request, host }, settings?.user);
|
return resolveTerminalLook({ ...request, host }, settings?.user);
|
||||||
}) as never);
|
}) as never);
|
||||||
|
|||||||
@@ -5,11 +5,19 @@ import type {
|
|||||||
TermixApp,
|
TermixApp,
|
||||||
} from "@termix/plugin-sdk/frontend";
|
} from "@termix/plugin-sdk/frontend";
|
||||||
|
|
||||||
|
export function macAddressOf(
|
||||||
|
host: Pick<PluginHostRecord, "pluginSettings"> | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
const macAddress = host?.pluginSettings?.["wake-on-lan"]?.macAddress;
|
||||||
|
return typeof macAddress === "string" && macAddress.trim()
|
||||||
|
? macAddress.trim()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
export function hasMacAddress(
|
export function hasMacAddress(
|
||||||
host: Pick<PluginHostRecord, "pluginSettings">,
|
host: Pick<PluginHostRecord, "pluginSettings">,
|
||||||
): boolean {
|
): boolean {
|
||||||
const macAddress = host.pluginSettings?.["wake-on-lan"]?.macAddress;
|
return macAddressOf(host) !== null;
|
||||||
return typeof macAddress === "string" && macAddress.trim().length > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown, fallback: string): string {
|
function errorMessage(error: unknown, fallback: string): string {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Zap } from "lucide-react";
|
import { Zap } from "lucide-react";
|
||||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||||
import { hasMacAddress, wakeHost } from "./host-action.js";
|
import { hasMacAddress, macAddressOf, wakeHost } from "./host-action.js";
|
||||||
|
|
||||||
export function activate(app: TermixApp): void {
|
export function activate(app: TermixApp): void {
|
||||||
app.registerHostAction({
|
app.registerHostAction({
|
||||||
@@ -12,4 +12,8 @@ export function activate(app: TermixApp): void {
|
|||||||
when: (host) => hasMacAddress(host),
|
when: (host) => hasMacAddress(host),
|
||||||
run: (host) => wakeHost(app.api, app.t, String(host.id)),
|
run: (host) => wakeHost(app.api, app.t, String(host.id)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A saved host's MAC address, for plugins that send their own wake packet.
|
||||||
|
app.registerAction("wakeOnLan.macAddress", ((hostId: string | number) =>
|
||||||
|
macAddressOf(app.getHost(hostId))) as never);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||||
import { hasMacAddress, wakeHost } from "../../src/frontend/host-action.js";
|
import {
|
||||||
|
hasMacAddress,
|
||||||
|
macAddressOf,
|
||||||
|
wakeHost,
|
||||||
|
} from "../../src/frontend/host-action.js";
|
||||||
|
|
||||||
vi.mock("sonner", () => ({
|
vi.mock("sonner", () => ({
|
||||||
toast: { success: vi.fn(), error: vi.fn() },
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
@@ -24,6 +28,26 @@ function stubApi(overrides: Partial<PluginApiClient> = {}): PluginApiClient {
|
|||||||
|
|
||||||
const t = (key: string) => key;
|
const t = (key: string) => key;
|
||||||
|
|
||||||
|
describe("macAddressOf", () => {
|
||||||
|
it("returns the trimmed MAC address a host keeps", () => {
|
||||||
|
expect(
|
||||||
|
macAddressOf({
|
||||||
|
pluginSettings: {
|
||||||
|
"wake-on-lan": { macAddress: " aa:bb:cc:dd:ee:ff " },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe("aa:bb:cc:dd:ee:ff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null without a host or an address", () => {
|
||||||
|
expect(macAddressOf(undefined)).toBeNull();
|
||||||
|
expect(macAddressOf({ pluginSettings: {} })).toBeNull();
|
||||||
|
expect(
|
||||||
|
macAddressOf({ pluginSettings: { "wake-on-lan": { macAddress: " " } } }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("hasMacAddress", () => {
|
describe("hasMacAddress", () => {
|
||||||
it("is true when the host's wake-on-lan settings carry a MAC address", () => {
|
it("is true when the host's wake-on-lan settings carry a MAC address", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
+49
-12
@@ -51,11 +51,20 @@ import {
|
|||||||
registerSecondFactor,
|
registerSecondFactor,
|
||||||
registerSshAuthEditor,
|
registerSshAuthEditor,
|
||||||
} from "./auth-registry";
|
} from "./auth-registry";
|
||||||
import { pluginHostBridge, resolvePluginPermission } from "./bridge";
|
import {
|
||||||
import { shell, tabsApi } from "./shell-bridge";
|
pluginHostBridge,
|
||||||
|
resolvePluginPermission,
|
||||||
|
toPluginHostRecord,
|
||||||
|
} from "./bridge";
|
||||||
|
import { shell, shellHost, tabsApi } from "./shell-bridge";
|
||||||
import { onRemoteServerChange, remoteServerUrl } from "./desktop";
|
import { onRemoteServerChange, remoteServerUrl } from "./desktop";
|
||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
import { withPluginScope, withIconBoundary, guardCallback } from "./scope";
|
import { withPluginScope, withIconBoundary, guardCallback } from "./scope";
|
||||||
|
import {
|
||||||
|
scopeHostArgs,
|
||||||
|
scopeHostCallback,
|
||||||
|
scopeHostFields,
|
||||||
|
} from "./host-scope";
|
||||||
import { manifestDeclares, type ViewKind } from "./view-ownership";
|
import { manifestDeclares, type ViewKind } from "./view-ownership";
|
||||||
import { pluginKey } from "@/lib/plugin-i18n";
|
import { pluginKey } from "@/lib/plugin-i18n";
|
||||||
import { hasPermission } from "@/hooks/use-permissions";
|
import { hasPermission } from "@/hooks/use-permissions";
|
||||||
@@ -98,6 +107,11 @@ export function createPluginApp(
|
|||||||
|
|
||||||
const key = (value: string) => pluginKey(pluginId, value);
|
const key = (value: string) => pluginKey(pluginId, value);
|
||||||
|
|
||||||
|
// Every host a plugin callback receives carries only this plugin's settings.
|
||||||
|
const hostCallback = <F extends (...args: never[]) => unknown>(
|
||||||
|
fn: F | undefined,
|
||||||
|
): F | undefined => scopeHostCallback(fn, pluginId);
|
||||||
|
|
||||||
const requireDeclared = (kind: ViewKind, id: string, what: string) => {
|
const requireDeclared = (kind: ViewKind, id: string, what: string) => {
|
||||||
if (!manifestDeclares(contributes, kind, id)) {
|
if (!manifestDeclares(contributes, kind, id)) {
|
||||||
const field =
|
const field =
|
||||||
@@ -221,17 +235,29 @@ export function createPluginApp(
|
|||||||
titleKey: key(action.titleKey),
|
titleKey: key(action.titleKey),
|
||||||
pluginId,
|
pluginId,
|
||||||
icon: withIconBoundary(pluginId, action.icon as never) as never,
|
icon: withIconBoundary(pluginId, action.icon as never) as never,
|
||||||
when: action.when as never,
|
when: hostCallback(action.when) as never,
|
||||||
run: action.run as never,
|
run: hostCallback(action.run) as never,
|
||||||
// Called while the host list renders, so a throw must not escape.
|
// Called while the host list renders, so a throw must not escape.
|
||||||
label: guardCallback(
|
label: guardCallback(
|
||||||
pluginId,
|
pluginId,
|
||||||
action.label as ((...args: unknown[]) => unknown) | undefined,
|
hostCallback(
|
||||||
|
action.label as ((...args: unknown[]) => unknown) | undefined,
|
||||||
|
),
|
||||||
undefined,
|
undefined,
|
||||||
) as never,
|
) as never,
|
||||||
items: guardCallback(
|
items: guardCallback(
|
||||||
pluginId,
|
pluginId,
|
||||||
action.items as ((...args: unknown[]) => unknown) | undefined,
|
action.items
|
||||||
|
? (...args: unknown[]) =>
|
||||||
|
(
|
||||||
|
action.items as (
|
||||||
|
...a: unknown[]
|
||||||
|
) => { run: (...a: never[]) => unknown }[]
|
||||||
|
)(...scopeHostArgs(args, pluginId))?.map((item) => ({
|
||||||
|
...item,
|
||||||
|
run: hostCallback(item.run),
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
undefined,
|
undefined,
|
||||||
) as never,
|
) as never,
|
||||||
}),
|
}),
|
||||||
@@ -256,7 +282,7 @@ export function createPluginApp(
|
|||||||
registerHostBadge({
|
registerHostBadge({
|
||||||
id: badge.id,
|
id: badge.id,
|
||||||
pluginId,
|
pluginId,
|
||||||
when: badge.when as never,
|
when: hostCallback(badge.when) as never,
|
||||||
component: scoped(badge.component) as never,
|
component: scoped(badge.component) as never,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -268,8 +294,8 @@ export function createPluginApp(
|
|||||||
...item,
|
...item,
|
||||||
titleKey: key(item.titleKey),
|
titleKey: key(item.titleKey),
|
||||||
pluginId,
|
pluginId,
|
||||||
when: item.when as never,
|
when: hostCallback(item.when) as never,
|
||||||
run: item.run as never,
|
run: hostCallback(item.run) as never,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -280,8 +306,8 @@ export function createPluginApp(
|
|||||||
...entry,
|
...entry,
|
||||||
titleKey: key(entry.titleKey),
|
titleKey: key(entry.titleKey),
|
||||||
pluginId,
|
pluginId,
|
||||||
when: entry.when as never,
|
when: hostCallback(entry.when) as never,
|
||||||
run: entry.run as never,
|
run: hostCallback(entry.run) as never,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -373,6 +399,13 @@ export function createPluginApp(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listHosts: () => pluginHostBridge.core.listHosts(pluginId),
|
||||||
|
|
||||||
|
getHost(hostId) {
|
||||||
|
const host = shellHost(hostId);
|
||||||
|
return host ? toPluginHostRecord(host, pluginId) : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
registerSettingsComponent(componentId, component) {
|
registerSettingsComponent(componentId, component) {
|
||||||
return track(
|
return track(
|
||||||
registerSettingsComponent(pluginId, componentId, scoped(component)),
|
registerSettingsComponent(pluginId, componentId, scoped(component)),
|
||||||
@@ -381,7 +414,7 @@ export function createPluginApp(
|
|||||||
|
|
||||||
registerAction(id, handler, options = {}) {
|
registerAction(id, handler, options = {}) {
|
||||||
return track(
|
return track(
|
||||||
registerAction(id, handler, {
|
registerAction(id, hostCallback(handler), {
|
||||||
permission: options.permission
|
permission: options.permission
|
||||||
? resolvePluginPermission(pluginId, options.permission)
|
? resolvePluginPermission(pluginId, options.permission)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -405,6 +438,10 @@ export function createPluginApp(
|
|||||||
component: contribution.component
|
component: contribution.component
|
||||||
? scoped(contribution.component)
|
? scoped(contribution.component)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
when: contribution.when
|
||||||
|
? (context: Record<string, unknown>) =>
|
||||||
|
contribution.when!(scopeHostFields(context, pluginId))
|
||||||
|
: undefined,
|
||||||
pluginId,
|
pluginId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -191,6 +191,17 @@ export function toHostRecord(host: ShellHost): PluginHostRecord {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The host as one plugin sees it: only that plugin's settings. */
|
||||||
|
export function toPluginHostRecord(
|
||||||
|
host: ShellHost,
|
||||||
|
pluginId: string | null,
|
||||||
|
): PluginHostRecord {
|
||||||
|
const record = toHostRecord(host);
|
||||||
|
const own = pluginId ? host.pluginSettings?.[pluginId] : undefined;
|
||||||
|
record.pluginSettings = own && pluginId ? { [pluginId]: own } : {};
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
export const pluginHostBridge: PluginHostBridge = {
|
export const pluginHostBridge: PluginHostBridge = {
|
||||||
usePluginId() {
|
usePluginId() {
|
||||||
const pluginId = usePluginScope();
|
const pluginId = usePluginScope();
|
||||||
@@ -257,19 +268,24 @@ export const pluginHostBridge: PluginHostBridge = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
useHost(hostId) {
|
useHost(hostId) {
|
||||||
|
const pluginId = usePluginScope();
|
||||||
const { hosts } = useShellHosts();
|
const { hosts } = useShellHosts();
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (hostId === undefined || hostId === null) return null;
|
if (hostId === undefined || hostId === null) return null;
|
||||||
const found = hosts.find((host) => host.id === String(hostId));
|
const found = hosts.find((host) => host.id === String(hostId));
|
||||||
return found ? toHostRecord(found) : null;
|
return found ? toPluginHostRecord(found, pluginId) : null;
|
||||||
}, [hosts, hostId]);
|
}, [hosts, hostId, pluginId]);
|
||||||
},
|
},
|
||||||
|
|
||||||
useHosts() {
|
useHosts() {
|
||||||
|
const pluginId = usePluginScope();
|
||||||
const { hosts, loaded } = useShellHosts();
|
const { hosts, loaded } = useShellHosts();
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({ hosts: hosts.map(toHostRecord), loaded }),
|
() => ({
|
||||||
[hosts, loaded],
|
hosts: hosts.map((host) => toPluginHostRecord(host, pluginId)),
|
||||||
|
loaded,
|
||||||
|
}),
|
||||||
|
[hosts, loaded, pluginId],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -410,8 +426,10 @@ export const pluginHostBridge: PluginHostBridge = {
|
|||||||
setClientPreference: (name, value) => {
|
setClientPreference: (name, value) => {
|
||||||
void setCookie(name, value, 365);
|
void setCookie(name, value, 365);
|
||||||
},
|
},
|
||||||
listHosts: async () =>
|
listHosts: async (pluginId) =>
|
||||||
(await getSSHHosts({ includeStatus: false })).map(toHostRecord),
|
(await getSSHHosts({ includeStatus: false })).map((host) =>
|
||||||
|
toPluginHostRecord(host, pluginId ?? null),
|
||||||
|
),
|
||||||
listCredentials: async () => {
|
listCredentials: async () => {
|
||||||
const raw = await getCredentials();
|
const raw = await getCredentials();
|
||||||
const list = Array.isArray(raw)
|
const list = Array.isArray(raw)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { shellHost } from "./shell-bridge";
|
||||||
|
|
||||||
|
type SettingsBag = Record<string, Record<string, unknown> | undefined>;
|
||||||
|
type HostLike = { id: string | number; pluginSettings?: SettingsBag };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plugin sees only its own host settings. Another plugin's settings reach
|
||||||
|
* it through that plugin's actions or services, never by reading the host,
|
||||||
|
* so a plugin can rename a setting without breaking its neighbours.
|
||||||
|
*
|
||||||
|
* Settings come from the shell's own copy of the host when it has one, so a
|
||||||
|
* host one plugin hands another (through an action or a tab) is re-read for
|
||||||
|
* the receiver instead of arriving with the sender's slice.
|
||||||
|
*/
|
||||||
|
export function isHostLike(value: unknown): value is HostLike {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
if (!("id" in value)) return false;
|
||||||
|
return "pluginSettings" in value || ("ip" in value && "port" in value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new WeakMap<
|
||||||
|
object,
|
||||||
|
Map<string, { source: SettingsBag | undefined; scoped: object }>
|
||||||
|
>();
|
||||||
|
|
||||||
|
export function scopeHost<T>(value: T, pluginId: string): T {
|
||||||
|
if (!isHostLike(value)) return value;
|
||||||
|
const full = (shellHost(value.id)?.pluginSettings ?? value.pluginSettings) as
|
||||||
|
SettingsBag | undefined;
|
||||||
|
let perPlugin = cache.get(value);
|
||||||
|
const hit = perPlugin?.get(pluginId);
|
||||||
|
// Same object in, same object out, so a plugin's effects keyed on the
|
||||||
|
// host don't re-run on every render.
|
||||||
|
if (hit && hit.source === full) return hit.scoped as T;
|
||||||
|
const own = full?.[pluginId];
|
||||||
|
const scoped = { ...value, pluginSettings: own ? { [pluginId]: own } : {} };
|
||||||
|
if (!perPlugin) {
|
||||||
|
perPlugin = new Map();
|
||||||
|
cache.set(value, perPlugin);
|
||||||
|
}
|
||||||
|
perPlugin.set(pluginId, { source: full, scoped });
|
||||||
|
return scoped as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scopes each argument that is a host, and the `host` / `sshHost` fields of
|
||||||
|
* an argument that is a request object such as `{ host, appTheme }`.
|
||||||
|
*/
|
||||||
|
export function scopeHostArgs<A extends unknown[]>(
|
||||||
|
args: A,
|
||||||
|
pluginId: string,
|
||||||
|
): A {
|
||||||
|
return args.map((arg) =>
|
||||||
|
isHostLike(arg) ? scopeHost(arg, pluginId) : scopeHostFields(arg, pluginId),
|
||||||
|
) as A;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The prop and field names hosts travel under between core and plugins.
|
||||||
|
const HOST_FIELDS = ["host", "sshHost", "hostConfig"] as const;
|
||||||
|
|
||||||
|
/** Scopes the host fields of a props, context or request object. */
|
||||||
|
export function scopeHostFields<T>(value: T, pluginId: string): T {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
let next: Record<string, unknown> | null = null;
|
||||||
|
for (const field of HOST_FIELDS) {
|
||||||
|
if (!(field in record)) continue;
|
||||||
|
const scoped = scopeHost(record[field], pluginId);
|
||||||
|
if (scoped === record[field]) continue;
|
||||||
|
next ??= { ...record };
|
||||||
|
next[field] = scoped;
|
||||||
|
}
|
||||||
|
return (next ?? value) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps a plugin callback so any host it is called with is scoped. */
|
||||||
|
export function scopeHostCallback<F extends (...args: never[]) => unknown>(
|
||||||
|
fn: F | undefined,
|
||||||
|
pluginId: string,
|
||||||
|
): F | undefined {
|
||||||
|
if (!fn) return fn;
|
||||||
|
return ((...args: never[]) =>
|
||||||
|
fn(...(scopeHostArgs(args as unknown[], pluginId) as never[]))) as F;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type ErrorInfo,
|
type ErrorInfo,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { scopeHostFields } from "./host-scope";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getPluginRecord } from "./plugin-store";
|
import { getPluginRecord } from "./plugin-store";
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ export function withPluginScope<P extends object>(
|
|||||||
function Scoped(props: P) {
|
function Scoped(props: P) {
|
||||||
return (
|
return (
|
||||||
<PluginScope pluginId={pluginId}>
|
<PluginScope pluginId={pluginId}>
|
||||||
<Inner {...props} />
|
<Inner {...scopeHostFields(props, pluginId)} />
|
||||||
</PluginScope>
|
</PluginScope>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,15 +86,23 @@ const noop = () => {};
|
|||||||
*/
|
*/
|
||||||
export function withShellHost(host: Host | null): Host | null {
|
export function withShellHost(host: Host | null): Host | null {
|
||||||
if (!host) return host;
|
if (!host) return host;
|
||||||
const own = hosts.find((entry) => String(entry.id) === String(host.id));
|
const own = shellHost(host.id);
|
||||||
if (!own || own === host) return host;
|
if (!own || own === host) return host;
|
||||||
const merged: Record<string, unknown> = { ...own };
|
const merged: Record<string, unknown> = { ...own };
|
||||||
for (const [key, value] of Object.entries(host)) {
|
for (const [key, value] of Object.entries(host)) {
|
||||||
if (value !== undefined) merged[key] = value;
|
if (value !== undefined) merged[key] = value;
|
||||||
}
|
}
|
||||||
|
// A plugin only ever holds its own slice of the settings.
|
||||||
|
merged.pluginSettings = own.pluginSettings;
|
||||||
return merged as Host;
|
return merged as Host;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The shell's own copy of a saved host, with every plugin's settings. */
|
||||||
|
export function shellHost(id: string | number | undefined): Host | undefined {
|
||||||
|
if (id === undefined || id === null) return undefined;
|
||||||
|
return hosts.find((entry) => String(entry.id) === String(id));
|
||||||
|
}
|
||||||
|
|
||||||
/** Forwards to the mounted shell; a no-op before it mounts or after logout. */
|
/** Forwards to the mounted shell; a no-op before it mounts or after logout. */
|
||||||
export const shell: TabShellCallbacks = {
|
export const shell: TabShellCallbacks = {
|
||||||
openTab: (host, ...rest) => callbacks?.openTab(withShellHost(host), ...rest),
|
openTab: (host, ...rest) => callbacks?.openTab(withShellHost(host), ...rest),
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { Puzzle } from "lucide-react";
|
||||||
|
import {
|
||||||
|
invokeAction,
|
||||||
|
useHost,
|
||||||
|
type PluginHostRecord,
|
||||||
|
type TermixApp,
|
||||||
|
} from "@termix/plugin-sdk/frontend";
|
||||||
|
import {
|
||||||
|
renderWithApp,
|
||||||
|
type RenderedPluginApp,
|
||||||
|
} from "@termix/plugin-sdk/testing";
|
||||||
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
import {
|
||||||
|
scopeHost,
|
||||||
|
scopeHostArgs,
|
||||||
|
scopeHostFields,
|
||||||
|
} from "@/plugin-host/host-scope";
|
||||||
|
import { setShellHosts, withShellHost } from "@/plugin-host/shell-bridge";
|
||||||
|
import { resetActionRegistry } from "@/shell/action-registry";
|
||||||
|
import { resetHostContributions } from "@/sidebar/host-contributions";
|
||||||
|
import { resetPanels } from "@/shell/panel-registry";
|
||||||
|
import { resetTabTypes } from "@/shell/tab-registry";
|
||||||
|
import { resetRegisteredRailItems } from "@/sidebar/rail-items";
|
||||||
|
import { resetPluginStore } from "@/plugin-host/plugin-store";
|
||||||
|
import type { Host } from "@/types/ui-types";
|
||||||
|
|
||||||
|
const SETTINGS = {
|
||||||
|
fixture: { enableFixture: true },
|
||||||
|
other: { secretKnob: "hands off" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const saved = {
|
||||||
|
id: "7",
|
||||||
|
name: "web-01",
|
||||||
|
ip: "10.0.0.7",
|
||||||
|
port: 22,
|
||||||
|
pluginSettings: SETTINGS,
|
||||||
|
} as unknown as Host;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setShellHosts([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scopeHost", () => {
|
||||||
|
it("keeps only the plugin's own settings", () => {
|
||||||
|
const scoped = scopeHost(saved, "fixture");
|
||||||
|
expect(scoped.pluginSettings).toEqual({ fixture: SETTINGS.fixture });
|
||||||
|
expect(scoped.name).toBe("web-01");
|
||||||
|
expect(saved.pluginSettings).toEqual(SETTINGS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a plugin with no settings on the host an empty bag", () => {
|
||||||
|
expect(scopeHost(saved, "nobody").pluginSettings).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads a host another plugin handed over from the shell's copy", () => {
|
||||||
|
setShellHosts([saved]);
|
||||||
|
const fromOther = scopeHost({ ...saved }, "other");
|
||||||
|
expect(fromOther.pluginSettings).toEqual({ other: SETTINGS.other });
|
||||||
|
expect(scopeHost(fromOther, "fixture").pluginSettings).toEqual({
|
||||||
|
fixture: SETTINGS.fixture,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the same object for the same host, so effects stay put", () => {
|
||||||
|
expect(scopeHost(saved, "fixture")).toBe(scopeHost(saved, "fixture"));
|
||||||
|
expect(scopeHost(saved, "fixture")).not.toBe(scopeHost(saved, "other"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes anything that is not a host through untouched", () => {
|
||||||
|
const shellApi = { openTab: () => {} };
|
||||||
|
expect(scopeHost(shellApi, "fixture")).toBe(shellApi);
|
||||||
|
expect(scopeHost("7", "fixture")).toBe("7");
|
||||||
|
expect(scopeHost(null, "fixture")).toBeNull();
|
||||||
|
const list = [saved];
|
||||||
|
expect(scopeHostFields(list, "fixture")).toBe(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes host fields of props and request objects", () => {
|
||||||
|
const props = { host: saved, sshHost: saved, hostConfig: saved, n: 1 };
|
||||||
|
const scoped = scopeHostFields(props, "fixture");
|
||||||
|
expect(scoped.host.pluginSettings).toEqual({ fixture: SETTINGS.fixture });
|
||||||
|
expect(scoped.sshHost.pluginSettings).toEqual({
|
||||||
|
fixture: SETTINGS.fixture,
|
||||||
|
});
|
||||||
|
expect(scoped.hostConfig.pluginSettings).toEqual({
|
||||||
|
fixture: SETTINGS.fixture,
|
||||||
|
});
|
||||||
|
expect(scoped.n).toBe(1);
|
||||||
|
const noHost = { appTheme: "dark" };
|
||||||
|
expect(scopeHostFields(noHost, "fixture")).toBe(noHost);
|
||||||
|
const [request] = scopeHostArgs([{ host: saved, appTheme: "dark" }], "x");
|
||||||
|
expect((request as { host: Host }).host.pluginSettings).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a tab opened from a plugin's host keep every plugin's settings", () => {
|
||||||
|
setShellHosts([saved]);
|
||||||
|
const merged = withShellHost(scopeHost(saved, "other"));
|
||||||
|
expect(merged?.pluginSettings).toEqual(SETTINGS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const MANIFEST: Partial<PluginManifest> = {
|
||||||
|
id: "fixture",
|
||||||
|
name: "Fixture",
|
||||||
|
contributes: {
|
||||||
|
tabs: [
|
||||||
|
{ id: "fixture-tab", titleKey: "title", icon: "Puzzle", openFrom: [] },
|
||||||
|
],
|
||||||
|
panels: [{ id: "fixture-panel", titleKey: "title" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let seen: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
function HostProbe() {
|
||||||
|
const host = useHost("7");
|
||||||
|
return <span>{JSON.stringify(host?.pluginSettings ?? null)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(app: TermixApp) {
|
||||||
|
app.registerPanel("fixture-panel", HostProbe);
|
||||||
|
app.registerTab("fixture-tab", ({ host }) => (
|
||||||
|
<span>{JSON.stringify(host?.pluginSettings ?? null)}</span>
|
||||||
|
));
|
||||||
|
app.registerHostAction({
|
||||||
|
id: "fixture-open",
|
||||||
|
titleKey: "title",
|
||||||
|
icon: Puzzle,
|
||||||
|
kind: "open",
|
||||||
|
when: (host) => {
|
||||||
|
seen.when = host.pluginSettings;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
app.registerAction("fixture.inspect", ((
|
||||||
|
host: PluginHostRecord,
|
||||||
|
request: { host: PluginHostRecord },
|
||||||
|
) => {
|
||||||
|
seen.action = host.pluginSettings;
|
||||||
|
seen.request = request.host.pluginSettings;
|
||||||
|
}) as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendered: RenderedPluginApp | null = null;
|
||||||
|
|
||||||
|
async function mount() {
|
||||||
|
rendered = await renderWithApp(
|
||||||
|
{ activate },
|
||||||
|
{
|
||||||
|
manifest: MANIFEST,
|
||||||
|
locales: { title: "Fixture" },
|
||||||
|
hosts: [saved as unknown as Record<string, unknown>],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("host settings a plugin sees", () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await rendered?.deactivate();
|
||||||
|
rendered = null;
|
||||||
|
seen = {};
|
||||||
|
resetActionRegistry();
|
||||||
|
resetHostContributions();
|
||||||
|
resetPanels();
|
||||||
|
resetTabTypes();
|
||||||
|
resetRegisteredRailItems();
|
||||||
|
resetPluginStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
const own = JSON.stringify({ fixture: SETTINGS.fixture });
|
||||||
|
|
||||||
|
it("useHost carries only the plugin's own settings", async () => {
|
||||||
|
const app = await mount();
|
||||||
|
app.renderPanel("fixture-panel");
|
||||||
|
expect(await screen.findByText(own)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a tab's host prop carries only the plugin's own settings", async () => {
|
||||||
|
const app = await mount();
|
||||||
|
app.renderTab("fixture-tab", { host: saved });
|
||||||
|
expect(await screen.findByText(own)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("app.getHost is scoped too", async () => {
|
||||||
|
const { app } = await mount();
|
||||||
|
expect(app.getHost(7)?.pluginSettings).toEqual({
|
||||||
|
fixture: SETTINGS.fixture,
|
||||||
|
});
|
||||||
|
expect(app.getHost(99)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("host action callbacks get the plugin's slice", async () => {
|
||||||
|
const app = await mount();
|
||||||
|
app.registered.hostActionsFor(saved);
|
||||||
|
expect(seen.when).toEqual({ fixture: SETTINGS.fixture });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an action handler gets a host another plugin passed, re-read for it", async () => {
|
||||||
|
await mount();
|
||||||
|
const fromOther = scopeHost(saved, "other");
|
||||||
|
await invokeAction("fixture.inspect", fromOther, { host: fromOther });
|
||||||
|
expect(seen.action).toEqual({ fixture: SETTINGS.fixture });
|
||||||
|
expect(seen.request).toEqual({ fixture: SETTINGS.fixture });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user