feat(FN-3093): address plugin manager responsive overflow
The merge fixes responsive overflow issues in the PluginManager component by adding 15 lines of CSS. Fusion-Task-Id: FN-3093
This commit is contained in:
@@ -1,4 +1,13 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type {
|
||||
FusionPlugin,
|
||||
PluginPromptContribution,
|
||||
PluginPromptContributions,
|
||||
PluginSetupHooks,
|
||||
PluginSetupManifest,
|
||||
PluginSkillContribution,
|
||||
PluginWorkflowStepContribution,
|
||||
} from "../plugin-types.js";
|
||||
import { validatePluginManifest } from "../plugin-types.js";
|
||||
|
||||
describe("validatePluginManifest", () => {
|
||||
@@ -991,3 +1000,187 @@ describe("PluginRuntimeRegistration", () => {
|
||||
expect(typeof registration.factory).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin contribution types", () => {
|
||||
it("accepts a minimal PluginSkillContribution shape", () => {
|
||||
const skill: PluginSkillContribution = {
|
||||
skillId: "browser-scan",
|
||||
name: "Browser Scan",
|
||||
description: "Scans web pages",
|
||||
skillFiles: ["skills/browser/SKILL.md"],
|
||||
};
|
||||
expect(skill.skillId).toBe("browser-scan");
|
||||
});
|
||||
|
||||
it("accepts a full PluginSkillContribution shape", () => {
|
||||
const skill: PluginSkillContribution = {
|
||||
skillId: "deep-research",
|
||||
name: "Deep Research",
|
||||
description: "Performs deep research tasks",
|
||||
skillFiles: ["skills/research/SKILL.md", "skills/research/README.md"],
|
||||
enabled: false,
|
||||
triggerPatterns: ["research", "investigate"],
|
||||
};
|
||||
expect(skill.enabled).toBe(false);
|
||||
expect(skill.triggerPatterns).toContain("research");
|
||||
});
|
||||
|
||||
it("accepts prompt and script workflow step contributions", () => {
|
||||
const promptStep: PluginWorkflowStepContribution = {
|
||||
stepId: "quality-review",
|
||||
name: "Quality Review",
|
||||
description: "Ask reviewer agent to evaluate quality",
|
||||
mode: "prompt",
|
||||
prompt: "Review this change",
|
||||
toolMode: "readonly",
|
||||
};
|
||||
const scriptStep: PluginWorkflowStepContribution = {
|
||||
stepId: "run-tests",
|
||||
name: "Run Tests",
|
||||
description: "Run test suite",
|
||||
mode: "script",
|
||||
scriptName: "test",
|
||||
toolMode: "coding",
|
||||
phase: "post-merge",
|
||||
};
|
||||
|
||||
expect(promptStep.mode).toBe("prompt");
|
||||
expect(scriptStep.mode).toBe("script");
|
||||
});
|
||||
|
||||
it("accepts all plugin prompt contribution surfaces", () => {
|
||||
const contributions: PluginPromptContribution[] = [
|
||||
{ surface: "executor-system", content: "executor system" },
|
||||
{ surface: "executor-task", content: "executor task", position: "prepend" },
|
||||
{ surface: "triage", content: "triage" },
|
||||
{ surface: "reviewer", content: "reviewer" },
|
||||
{ surface: "heartbeat", content: "heartbeat", condition: "only for heartbeat audits" },
|
||||
];
|
||||
|
||||
expect(contributions).toHaveLength(5);
|
||||
expect(contributions[1]?.position).toBe("prepend");
|
||||
expect(contributions[4]?.condition).toContain("heartbeat");
|
||||
});
|
||||
|
||||
it("accepts prompt contributions wrapper with optional enabledByDefault", () => {
|
||||
const promptContributions: PluginPromptContributions = {
|
||||
contributions: [{ surface: "triage", content: "Always gather constraints" }],
|
||||
};
|
||||
|
||||
expect(promptContributions.enabledByDefault).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts setup manifest and hooks shapes", async () => {
|
||||
const manifest: PluginSetupManifest = {
|
||||
binaryName: "agent-browser",
|
||||
description: "Headless browser runtime",
|
||||
channel: "stable",
|
||||
defaultTimeoutMs: 120000,
|
||||
};
|
||||
|
||||
const hooks: PluginSetupHooks = {
|
||||
checkSetup: async () => ({ status: "installed", version: "1.2.3", binaryPath: "/tmp/agent-browser" }),
|
||||
install: async () => {},
|
||||
uninstall: async () => {},
|
||||
};
|
||||
|
||||
const result = await hooks.checkSetup({} as any);
|
||||
expect(manifest.binaryName).toBe("agent-browser");
|
||||
expect(result.status).toBe("installed");
|
||||
});
|
||||
|
||||
it("accepts FusionPlugin with all new contribution types and remains backward compatible", () => {
|
||||
const withContributions: FusionPlugin = {
|
||||
manifest: { id: "full-plugin", name: "Full Plugin", version: "1.0.0" },
|
||||
state: "installed",
|
||||
hooks: {},
|
||||
skills: [{ skillId: "web-tools", name: "Web Tools", description: "Web helper", skillFiles: ["skills/SKILL.md"] }],
|
||||
workflowSteps: [{ stepId: "verify", name: "Verify", description: "Verify output", mode: "prompt", prompt: "verify" }],
|
||||
promptContributions: {
|
||||
enabledByDefault: false,
|
||||
contributions: [{ surface: "reviewer", content: "Use strict review" }],
|
||||
},
|
||||
setup: {
|
||||
manifest: { binaryName: "agent-browser", description: "Browser runtime" },
|
||||
hooks: {
|
||||
checkSetup: async () => ({ status: "not-installed" }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const backwardCompatible: FusionPlugin = {
|
||||
manifest: { id: "legacy-plugin", name: "Legacy Plugin", version: "1.0.0" },
|
||||
state: "installed",
|
||||
hooks: {},
|
||||
};
|
||||
|
||||
expect(withContributions.skills?.[0]?.skillId).toBe("web-tools");
|
||||
expect(backwardCompatible.skills).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePluginManifest contribution metadata", () => {
|
||||
it("accepts valid contribution metadata", () => {
|
||||
const result = validatePluginManifest({
|
||||
id: "plugin-a",
|
||||
name: "Plugin A",
|
||||
version: "1.0.0",
|
||||
skills: [{ skillId: "web-reader", name: "Web Reader" }],
|
||||
workflowSteps: [{ stepId: "quality-gate", name: "Quality Gate", mode: "prompt" }],
|
||||
promptSurfaces: ["executor-system", "reviewer"],
|
||||
setup: { binaryName: "agent-browser", description: "Browser runtime", channel: "beta" },
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects invalid skill slug metadata", () => {
|
||||
const result = validatePluginManifest({
|
||||
id: "plugin-a",
|
||||
name: "Plugin A",
|
||||
version: "1.0.0",
|
||||
skills: [{ skillId: "Bad Skill", name: "Skill" }],
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("skills[0].skillId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)");
|
||||
});
|
||||
|
||||
it("rejects invalid workflow step mode metadata", () => {
|
||||
const result = validatePluginManifest({
|
||||
id: "plugin-a",
|
||||
name: "Plugin A",
|
||||
version: "1.0.0",
|
||||
workflowSteps: [{ stepId: "quality-gate", name: "Quality Gate", mode: "invalid" }],
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("workflowSteps[0].mode must be one of: prompt, script");
|
||||
});
|
||||
|
||||
it("rejects invalid prompt surfaces metadata", () => {
|
||||
const result = validatePluginManifest({
|
||||
id: "plugin-a",
|
||||
name: "Plugin A",
|
||||
version: "1.0.0",
|
||||
promptSurfaces: ["invalid-surface"],
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("promptSurfaces[0] must be one of: executor-system, executor-task, triage, reviewer, heartbeat");
|
||||
});
|
||||
|
||||
it("rejects incomplete setup metadata", () => {
|
||||
const result = validatePluginManifest({
|
||||
id: "plugin-a",
|
||||
name: "Plugin A",
|
||||
version: "1.0.0",
|
||||
setup: { binaryName: "" },
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("setup.binaryName is required and must be a non-empty string");
|
||||
expect(result.errors).toContain("setup.description is required and must be a non-empty string");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,6 +145,15 @@ export type {
|
||||
PluginRuntimeRegistration,
|
||||
PluginContext,
|
||||
PluginLogger,
|
||||
PluginSkillContribution,
|
||||
PluginWorkflowStepContribution,
|
||||
PluginPromptSurface,
|
||||
PluginPromptContribution,
|
||||
PluginPromptContributions,
|
||||
PluginSetupStatus,
|
||||
PluginSetupCheckResult,
|
||||
PluginSetupHooks,
|
||||
PluginSetupManifest,
|
||||
FusionPlugin,
|
||||
PluginState,
|
||||
PluginInstallation,
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
*/
|
||||
|
||||
import type { TaskStore } from "./store.js";
|
||||
import type { Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js";
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
||||
const PROMPT_CONTRIBUTION_SURFACES = ["executor-system", "executor-task", "triage", "reviewer", "heartbeat"] as const;
|
||||
const SETUP_CHANNELS = ["stable", "beta", "nightly"] as const;
|
||||
|
||||
// ── Plugin Manifest ───────────────────────────────────────────────────
|
||||
|
||||
@@ -39,6 +44,14 @@ export interface PluginManifest {
|
||||
settingsSchema?: Record<string, PluginSettingSchema>;
|
||||
/** Optional agent runtime metadata for discovery (runtime factory is in FusionPlugin.runtime) */
|
||||
runtime?: PluginRuntimeManifestMetadata;
|
||||
/** Optional skill metadata used for discovery UIs. */
|
||||
skills?: Array<{ skillId: string; name: string }>;
|
||||
/** Optional workflow step metadata used for discovery UIs. */
|
||||
workflowSteps?: Array<{ stepId: string; name: string }>;
|
||||
/** Prompt surfaces this plugin contributes to. */
|
||||
promptSurfaces?: PluginPromptSurface[];
|
||||
/** Setup metadata for plugin-managed binaries/runtimes. */
|
||||
setup?: PluginSetupManifest;
|
||||
}
|
||||
|
||||
// ── Plugin Setting Schema ──────────────────────────────────────────────
|
||||
@@ -206,6 +219,122 @@ export interface PluginRuntimeRegistration {
|
||||
factory: PluginRuntimeFactory;
|
||||
}
|
||||
|
||||
// ── Plugin Contribution Types ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Plugin-contributed skill surfaced in agent sessions via the skill-selection system.
|
||||
*/
|
||||
export interface PluginSkillContribution {
|
||||
/** Unique skill identifier within the plugin namespace (kebab-case). */
|
||||
skillId: string;
|
||||
/** Human-readable skill name. */
|
||||
name: string;
|
||||
/** What the skill does. */
|
||||
description: string;
|
||||
/** Paths (relative to plugin root) to SKILL.md or equivalent definitions. */
|
||||
skillFiles: string[];
|
||||
/** Whether this skill is enabled by default. Defaults to true. */
|
||||
enabled?: boolean;
|
||||
/** Optional keyword/pattern hints used by skill matching. */
|
||||
triggerPatterns?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow step template contributed by a plugin. These templates are
|
||||
* materialized into concrete WorkflowStep instances when selected for a task.
|
||||
*/
|
||||
export interface PluginWorkflowStepContribution {
|
||||
/** Unique step identifier within the plugin namespace (kebab-case). */
|
||||
stepId: string;
|
||||
/** Human-readable step name. */
|
||||
name: string;
|
||||
/** Short description for UI. */
|
||||
description: string;
|
||||
/** Execution mode, aligned with WorkflowStepMode. */
|
||||
mode: WorkflowStepMode;
|
||||
/** Task lifecycle phase where this step runs. Defaults to "pre-merge". */
|
||||
phase?: "pre-merge" | "post-merge";
|
||||
/** Prompt text used when mode is "prompt". */
|
||||
prompt?: string;
|
||||
/** Script name used when mode is "script". */
|
||||
scriptName?: string;
|
||||
/** Tool access level, aligned with WorkflowStepToolMode. */
|
||||
toolMode?: WorkflowStepToolMode;
|
||||
/** Whether this step is enabled by default. Defaults to true. */
|
||||
enabled?: boolean;
|
||||
/** Whether this step is auto-selected on new tasks. */
|
||||
defaultOn?: boolean;
|
||||
/** Optional model provider override for prompt steps. */
|
||||
modelProvider?: string;
|
||||
/** Optional model ID override for prompt steps. */
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt injection surfaces for plugin-contributed instructions.
|
||||
* - executor-system: Appended to executor agent system prompt
|
||||
* - executor-task: Injected into per-task execution context
|
||||
* - triage: Appended to triage/planning prompts
|
||||
* - reviewer: Appended to reviewer/validation prompts
|
||||
* - heartbeat: Appended to heartbeat agent system prompts
|
||||
*/
|
||||
export type PluginPromptSurface = (typeof PROMPT_CONTRIBUTION_SURFACES)[number];
|
||||
|
||||
export interface PluginPromptContribution {
|
||||
/** Which prompt surface this contribution targets. */
|
||||
surface: PluginPromptSurface;
|
||||
/** Prompt text to inject. */
|
||||
content: string;
|
||||
/** Position relative to existing prompt content. Defaults to "append". */
|
||||
position?: "append" | "prepend";
|
||||
/** Human-readable applicability description, reserved for future filtering. */
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
export interface PluginPromptContributions {
|
||||
contributions: PluginPromptContribution[];
|
||||
/** Whether contributions are active by default. Defaults to false for safety. */
|
||||
enabledByDefault?: boolean;
|
||||
}
|
||||
|
||||
export type PluginSetupStatus = "not-installed" | "installing" | "installed" | "error";
|
||||
|
||||
export interface PluginSetupCheckResult {
|
||||
status: PluginSetupStatus;
|
||||
/** Installed version if available. */
|
||||
version?: string;
|
||||
/** Installed binary path if detected. */
|
||||
binaryPath?: string;
|
||||
/** Error details when status is "error". */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin-managed setup hooks. All process execution in hooks MUST be async
|
||||
* (never execSync) to avoid blocking the engine event loop.
|
||||
*/
|
||||
export interface PluginSetupHooks {
|
||||
/** Check whether required binaries/runtimes are installed and ready. */
|
||||
checkSetup: (ctx: PluginContext) => Promise<PluginSetupCheckResult>;
|
||||
/** Install required binaries/runtimes. */
|
||||
install?: (ctx: PluginContext) => Promise<void>;
|
||||
/** Uninstall managed binaries/runtimes. */
|
||||
uninstall?: (ctx: PluginContext) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginSetupManifest {
|
||||
/** Binary/runtime name being managed (e.g. "agent-browser"). */
|
||||
binaryName: string;
|
||||
/** What this binary/runtime provides. */
|
||||
description: string;
|
||||
/** Expected or pinned version. */
|
||||
version?: string;
|
||||
/** Installation channel. */
|
||||
channel?: (typeof SETUP_CHANNELS)[number];
|
||||
/** Timeout for setup/install commands. Defaults to 120000. */
|
||||
defaultTimeoutMs?: number;
|
||||
}
|
||||
|
||||
// ── Fusion Plugin ────────────────────────────────────────────────────
|
||||
|
||||
export type PluginState = "installed" | "started" | "stopped" | "error";
|
||||
@@ -229,6 +358,17 @@ export interface FusionPlugin {
|
||||
uiSlots?: PluginUiSlotDefinition[];
|
||||
/** Agent runtime registration for providing custom runtime implementations */
|
||||
runtime?: PluginRuntimeRegistration;
|
||||
/** Plugin-contributed skills surfaced by the skill resolver. */
|
||||
skills?: PluginSkillContribution[];
|
||||
/** Plugin-contributed workflow step templates. */
|
||||
workflowSteps?: PluginWorkflowStepContribution[];
|
||||
/** Plugin-contributed prompt injections. */
|
||||
promptContributions?: PluginPromptContributions;
|
||||
/** Plugin-managed setup metadata and lifecycle hooks. */
|
||||
setup?: {
|
||||
manifest: PluginSetupManifest;
|
||||
hooks: PluginSetupHooks;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Plugin Installation ───────────────────────────────────────────────
|
||||
@@ -281,7 +421,7 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err
|
||||
// Required fields
|
||||
if (!m.id || typeof m.id !== "string" || m.id.trim() === "") {
|
||||
errors.push("id is required and must be a non-empty string");
|
||||
} else if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(m.id)) {
|
||||
} else if (!SLUG_PATTERN.test(m.id)) {
|
||||
errors.push("id must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)");
|
||||
}
|
||||
|
||||
@@ -344,7 +484,7 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err
|
||||
// runtimeId is required
|
||||
if (!runtime.runtimeId || typeof runtime.runtimeId !== "string" || runtime.runtimeId.trim() === "") {
|
||||
errors.push("runtime.runtimeId is required and must be a non-empty string");
|
||||
} else if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(runtime.runtimeId as string)) {
|
||||
} else if (!SLUG_PATTERN.test(runtime.runtimeId as string)) {
|
||||
errors.push("runtime.runtimeId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)");
|
||||
}
|
||||
|
||||
@@ -364,12 +504,92 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: plugin skill discovery metadata
|
||||
if (m.skills !== undefined) {
|
||||
if (!Array.isArray(m.skills)) {
|
||||
errors.push("skills must be an array");
|
||||
} else {
|
||||
for (const [index, skill] of m.skills.entries()) {
|
||||
if (!skill || typeof skill !== "object") {
|
||||
errors.push(`skills[${index}] must be an object`);
|
||||
continue;
|
||||
}
|
||||
const skillMeta = skill as Record<string, unknown>;
|
||||
if (!skillMeta.skillId || typeof skillMeta.skillId !== "string" || skillMeta.skillId.trim() === "") {
|
||||
errors.push(`skills[${index}].skillId is required and must be a non-empty string`);
|
||||
} else if (!SLUG_PATTERN.test(skillMeta.skillId)) {
|
||||
errors.push(`skills[${index}].skillId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`);
|
||||
}
|
||||
if (!skillMeta.name || typeof skillMeta.name !== "string" || skillMeta.name.trim() === "") {
|
||||
errors.push(`skills[${index}].name is required and must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: plugin workflow step discovery metadata
|
||||
if (m.workflowSteps !== undefined) {
|
||||
if (!Array.isArray(m.workflowSteps)) {
|
||||
errors.push("workflowSteps must be an array");
|
||||
} else {
|
||||
for (const [index, step] of m.workflowSteps.entries()) {
|
||||
if (!step || typeof step !== "object") {
|
||||
errors.push(`workflowSteps[${index}] must be an object`);
|
||||
continue;
|
||||
}
|
||||
const stepMeta = step as Record<string, unknown>;
|
||||
if (!stepMeta.stepId || typeof stepMeta.stepId !== "string" || stepMeta.stepId.trim() === "") {
|
||||
errors.push(`workflowSteps[${index}].stepId is required and must be a non-empty string`);
|
||||
} else if (!SLUG_PATTERN.test(stepMeta.stepId)) {
|
||||
errors.push(`workflowSteps[${index}].stepId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`);
|
||||
}
|
||||
if (!stepMeta.name || typeof stepMeta.name !== "string" || stepMeta.name.trim() === "") {
|
||||
errors.push(`workflowSteps[${index}].name is required and must be a non-empty string`);
|
||||
}
|
||||
if (stepMeta.mode !== undefined && (typeof stepMeta.mode !== "string" || !["prompt", "script"].includes(stepMeta.mode))) {
|
||||
errors.push(`workflowSteps[${index}].mode must be one of: prompt, script`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: prompt surface metadata
|
||||
if (m.promptSurfaces !== undefined) {
|
||||
if (!Array.isArray(m.promptSurfaces)) {
|
||||
errors.push("promptSurfaces must be an array");
|
||||
} else {
|
||||
for (const [index, surface] of m.promptSurfaces.entries()) {
|
||||
if (typeof surface !== "string" || !PROMPT_CONTRIBUTION_SURFACES.includes(surface as PluginPromptSurface)) {
|
||||
errors.push(`promptSurfaces[${index}] must be one of: ${PROMPT_CONTRIBUTION_SURFACES.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: setup manifest metadata
|
||||
if (m.setup !== undefined) {
|
||||
if (typeof m.setup !== "object" || m.setup === null) {
|
||||
errors.push("setup must be an object");
|
||||
} else {
|
||||
const setup = m.setup as Record<string, unknown>;
|
||||
if (!setup.binaryName || typeof setup.binaryName !== "string" || setup.binaryName.trim() === "") {
|
||||
errors.push("setup.binaryName is required and must be a non-empty string");
|
||||
}
|
||||
if (!setup.description || typeof setup.description !== "string" || setup.description.trim() === "") {
|
||||
errors.push("setup.description is required and must be a non-empty string");
|
||||
}
|
||||
if (setup.channel !== undefined && (typeof setup.channel !== "string" || !SETUP_CHANNELS.includes(setup.channel as (typeof SETUP_CHANNELS)[number]))) {
|
||||
errors.push(`setup.channel must be one of: ${SETUP_CHANNELS.join(", ")}`);
|
||||
}
|
||||
if (setup.defaultTimeoutMs !== undefined && (typeof setup.defaultTimeoutMs !== "number" || !Number.isFinite(setup.defaultTimeoutMs) || setup.defaultTimeoutMs <= 0)) {
|
||||
errors.push("setup.defaultTimeoutMs must be a positive finite number");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Re-export Task type for hook signatures ───────────────────────────
|
||||
// The Task type is used in hook signatures; we import it via types.js
|
||||
import type { Task } from "./types.js";
|
||||
|
||||
Reference in New Issue
Block a user