2026-05-08 05:28:43 +02:00
|
|
|
/**
|
|
|
|
|
* Skills system tests.
|
|
|
|
|
*
|
|
|
|
|
* Purpose: verify skill discovery, frontmatter parsing, validation, and loading.
|
|
|
|
|
*/
|
|
|
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
|
|
import { tmpdir } from "node:os";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
|
|
|
import { discoverSkillDirs, readSkillFile } from "../skills/directory.js";
|
|
|
|
|
import {
|
|
|
|
|
buildSkillRecord,
|
|
|
|
|
parseSkillFrontmatter,
|
|
|
|
|
validateSkillFrontmatter,
|
|
|
|
|
} from "../skills/frontmatter.js";
|
|
|
|
|
import {
|
|
|
|
|
getModelInvocableSkills,
|
|
|
|
|
getPermittedSkills,
|
2026-05-08 23:52:35 +02:00
|
|
|
getUserInvocableSkills,
|
2026-05-08 05:28:43 +02:00
|
|
|
loadSkills,
|
|
|
|
|
} from "../skills/loader.js";
|
2026-05-14 19:32:41 +02:00
|
|
|
import { getSkillSearchDirs } from "../preferences-skills.js";
|
2026-05-08 05:28:43 +02:00
|
|
|
|
|
|
|
|
describe("skill frontmatter", () => {
|
|
|
|
|
test("parseSkillFrontmatter_extracts_yaml_and_body", () => {
|
|
|
|
|
const content = `---
|
|
|
|
|
name: test-skill
|
|
|
|
|
description: A test skill
|
|
|
|
|
user-invocable: true
|
|
|
|
|
model-invocable: false
|
|
|
|
|
side-effects: none
|
|
|
|
|
permission-profile: restricted
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
# Test Skill
|
|
|
|
|
|
|
|
|
|
Some instructions.
|
|
|
|
|
`;
|
|
|
|
|
const parsed = parseSkillFrontmatter(content);
|
|
|
|
|
expect(parsed).toBeTruthy();
|
|
|
|
|
expect(parsed.frontmatter.name).toBe("test-skill");
|
|
|
|
|
expect(parsed.frontmatter.description).toBe("A test skill");
|
|
|
|
|
expect(parsed.frontmatter["user-invocable"]).toBe(true);
|
|
|
|
|
expect(parsed.frontmatter["model-invocable"]).toBe(false);
|
|
|
|
|
expect(parsed.frontmatter["side-effects"]).toBe("none");
|
|
|
|
|
expect(parsed.frontmatter["permission-profile"]).toBe("restricted");
|
|
|
|
|
expect(parsed.body).toContain("# Test Skill");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("parseSkillFrontmatter_returns_null_without_frontmatter", () => {
|
|
|
|
|
const parsed = parseSkillFrontmatter("# Just markdown\n\nNo frontmatter.");
|
|
|
|
|
expect(parsed).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("validateSkillFrontmatter_passes_complete_frontmatter", () => {
|
|
|
|
|
const result = validateSkillFrontmatter({
|
|
|
|
|
name: "test",
|
|
|
|
|
description: "desc",
|
|
|
|
|
"user-invocable": true,
|
|
|
|
|
"model-invocable": false,
|
|
|
|
|
"side-effects": "none",
|
|
|
|
|
"permission-profile": "normal",
|
|
|
|
|
});
|
|
|
|
|
expect(result.valid).toBe(true);
|
|
|
|
|
expect(result.errors).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-12 16:45:04 +02:00
|
|
|
test("validateSkillFrontmatter_passes_minimal_ecosystem_frontmatter", () => {
|
|
|
|
|
// Ecosystem standard: only name + description required
|
|
|
|
|
const result = validateSkillFrontmatter({
|
|
|
|
|
name: "test",
|
|
|
|
|
description: "desc",
|
|
|
|
|
});
|
|
|
|
|
expect(result.valid).toBe(true);
|
|
|
|
|
expect(result.errors).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-08 05:28:43 +02:00
|
|
|
test("validateSkillFrontmatter_fails_missing_fields", () => {
|
|
|
|
|
const result = validateSkillFrontmatter({
|
|
|
|
|
name: "test",
|
|
|
|
|
});
|
|
|
|
|
expect(result.valid).toBe(false);
|
|
|
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
|
|
|
expect(result.errors.some((e) => e.includes("description"))).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("validateSkillFrontmatter_fails_invalid_permission_profile", () => {
|
|
|
|
|
const result = validateSkillFrontmatter({
|
|
|
|
|
name: "test",
|
|
|
|
|
description: "desc",
|
|
|
|
|
"user-invocable": true,
|
|
|
|
|
"model-invocable": false,
|
|
|
|
|
"side-effects": "none",
|
|
|
|
|
"permission-profile": "invalid",
|
|
|
|
|
});
|
|
|
|
|
expect(result.valid).toBe(false);
|
|
|
|
|
expect(result.errors.some((e) => e.includes("permission-profile"))).toBe(
|
|
|
|
|
true,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("buildSkillRecord_creates_canonical_record", () => {
|
|
|
|
|
const record = buildSkillRecord(
|
|
|
|
|
"/path/to/skill",
|
|
|
|
|
{
|
|
|
|
|
name: "test-skill",
|
|
|
|
|
description: "A test",
|
|
|
|
|
"user-invocable": true,
|
|
|
|
|
"model-invocable": true,
|
|
|
|
|
"side-effects": "code-edits",
|
|
|
|
|
"permission-profile": "normal",
|
|
|
|
|
triggers: ["build", "code"],
|
|
|
|
|
"max-activations": 5,
|
|
|
|
|
},
|
|
|
|
|
"# Body",
|
|
|
|
|
);
|
|
|
|
|
expect(record.name).toBe("test-skill");
|
|
|
|
|
expect(record.userInvocable).toBe(true);
|
|
|
|
|
expect(record.modelInvocable).toBe(true);
|
|
|
|
|
expect(record.sideEffects).toBe("code-edits");
|
|
|
|
|
expect(record.permissionProfile).toBe("normal");
|
|
|
|
|
expect(record.triggers).toEqual(["build", "code"]);
|
|
|
|
|
expect(record.maxActivations).toBe(5);
|
|
|
|
|
expect(record.body).toBe("# Body");
|
|
|
|
|
expect(record.path).toBe("/path/to/skill");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("skill discovery", () => {
|
|
|
|
|
let tmpDir;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
tmpDir = mkdtempSync(join(tmpdir(), "sf-skills-test-"));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("discoverSkillDirs_finds_skills_with_skill_md", () => {
|
|
|
|
|
const skillDir = join(tmpDir, ".agents", "skills", "test-skill");
|
|
|
|
|
mkdirSync(skillDir, { recursive: true });
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(skillDir, "SKILL.md"),
|
|
|
|
|
`---\nname: test-skill\ndescription: Test\nuser-invocable: true\nmodel-invocable: true\nside-effects: none\npermission-profile: restricted\n---\n\n# Test\n`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const dirs = discoverSkillDirs(tmpDir);
|
|
|
|
|
expect(dirs).toHaveLength(1);
|
|
|
|
|
expect(dirs[0].name).toBe("test-skill");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("discoverSkillDirs_ignores_dirs_without_skill_md", () => {
|
|
|
|
|
const skillDir = join(tmpDir, ".agents", "skills", "empty-dir");
|
|
|
|
|
mkdirSync(skillDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
const dirs = discoverSkillDirs(tmpDir);
|
|
|
|
|
expect(dirs).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("readSkillFile_reads_content", () => {
|
|
|
|
|
const skillDir = join(tmpDir, ".agents", "skills", "test-skill");
|
|
|
|
|
mkdirSync(skillDir, { recursive: true });
|
|
|
|
|
writeFileSync(join(skillDir, "SKILL.md"), "# Test Skill\n");
|
|
|
|
|
|
|
|
|
|
const content = readSkillFile(skillDir);
|
|
|
|
|
expect(content).toBe("# Test Skill\n");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("readSkillFile_returns_null_for_missing", () => {
|
|
|
|
|
const content = readSkillFile(join(tmpDir, "nonexistent"));
|
|
|
|
|
expect(content).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("skill loading", () => {
|
|
|
|
|
let tmpDir;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
tmpDir = mkdtempSync(join(tmpdir(), "sf-skills-test-"));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function createSkill(name, overrides = {}) {
|
|
|
|
|
const skillDir = join(tmpDir, ".agents", "skills", name);
|
|
|
|
|
mkdirSync(skillDir, { recursive: true });
|
|
|
|
|
const frontmatter = {
|
|
|
|
|
name,
|
|
|
|
|
description: overrides.description ?? `Description for ${name}`,
|
|
|
|
|
"user-invocable": overrides.userInvocable ?? true,
|
|
|
|
|
"model-invocable": overrides.modelInvocable ?? true,
|
|
|
|
|
"side-effects": overrides.sideEffects ?? "none",
|
|
|
|
|
"permission-profile": overrides.permissionProfile ?? "normal",
|
|
|
|
|
triggers: overrides.triggers ?? ["*"],
|
|
|
|
|
...overrides.extra,
|
|
|
|
|
};
|
|
|
|
|
const yaml = Object.entries(frontmatter)
|
|
|
|
|
.map(([k, v]) => {
|
|
|
|
|
if (Array.isArray(v))
|
|
|
|
|
return `${k}:\n${v.map((i) => ` - ${i}`).join("\n")}`;
|
|
|
|
|
return `${k}: ${v}`;
|
|
|
|
|
})
|
|
|
|
|
.join("\n");
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(skillDir, "SKILL.md"),
|
|
|
|
|
`---\n${yaml}\n---\n\n# ${name}\n`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test("loadSkills_loads_valid_skills", () => {
|
|
|
|
|
createSkill("skill-a");
|
|
|
|
|
createSkill("skill-b", { permissionProfile: "trusted" });
|
|
|
|
|
|
2026-05-09 02:55:16 +02:00
|
|
|
const skills = loadSkills(tmpDir, { includeWorkflow: false });
|
2026-05-08 05:28:43 +02:00
|
|
|
expect(skills).toHaveLength(2);
|
|
|
|
|
expect(skills.every((s) => s.valid)).toBe(true);
|
|
|
|
|
expect(skills.some((s) => s.name === "skill-a")).toBe(true);
|
|
|
|
|
expect(skills.some((s) => s.name === "skill-b")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-14 19:32:41 +02:00
|
|
|
test("loadSkills_project_skill_uses_repo_overlay_without_hidden_locking", () => {
|
|
|
|
|
createSkill("sf-repo-orientation");
|
|
|
|
|
|
|
|
|
|
const skills = loadSkills(tmpDir, {
|
|
|
|
|
includeWorkflow: true,
|
|
|
|
|
includeBundled: true,
|
|
|
|
|
});
|
|
|
|
|
const repoMap = skills.filter((s) => s.name === "sf-repo-orientation");
|
|
|
|
|
|
|
|
|
|
expect(repoMap).toHaveLength(1);
|
|
|
|
|
expect(repoMap[0].source).toBe("project");
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-08 05:28:43 +02:00
|
|
|
test("loadSkills_marks_invalid_skills", () => {
|
|
|
|
|
createSkill("valid-skill");
|
|
|
|
|
const badDir = join(tmpDir, ".agents", "skills", "bad-skill");
|
|
|
|
|
mkdirSync(badDir, { recursive: true });
|
|
|
|
|
writeFileSync(join(badDir, "SKILL.md"), "No frontmatter here.");
|
|
|
|
|
|
2026-05-09 02:55:16 +02:00
|
|
|
const skills = loadSkills(tmpDir, { includeWorkflow: false });
|
2026-05-08 05:28:43 +02:00
|
|
|
expect(skills).toHaveLength(2);
|
|
|
|
|
const bad = skills.find((s) => s.name === "bad-skill");
|
|
|
|
|
expect(bad).toBeTruthy();
|
|
|
|
|
expect(bad.valid).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("getPermittedSkills_filters_by_profile", () => {
|
|
|
|
|
createSkill("restricted-skill", { permissionProfile: "restricted" });
|
|
|
|
|
createSkill("normal-skill", { permissionProfile: "normal" });
|
|
|
|
|
createSkill("trusted-skill", { permissionProfile: "trusted" });
|
|
|
|
|
|
2026-05-09 02:55:16 +02:00
|
|
|
const skills = loadSkills(tmpDir, { includeWorkflow: false });
|
2026-05-08 05:28:43 +02:00
|
|
|
const permitted = getPermittedSkills(skills, "normal");
|
|
|
|
|
expect(permitted).toHaveLength(2);
|
|
|
|
|
expect(permitted.some((s) => s.name === "restricted-skill")).toBe(true);
|
|
|
|
|
expect(permitted.some((s) => s.name === "normal-skill")).toBe(true);
|
|
|
|
|
expect(permitted.some((s) => s.name === "trusted-skill")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("getModelInvocableSkills_filters_by_work_mode", () => {
|
|
|
|
|
createSkill("build-skill", { triggers: ["build"], modelInvocable: true });
|
|
|
|
|
createSkill("review-skill", { triggers: ["review"], modelInvocable: true });
|
|
|
|
|
createSkill("universal-skill", { triggers: ["*"], modelInvocable: true });
|
|
|
|
|
createSkill("user-only", { triggers: ["*"], modelInvocable: false });
|
|
|
|
|
|
2026-05-09 02:55:16 +02:00
|
|
|
const skills = loadSkills(tmpDir, { includeWorkflow: false });
|
2026-05-08 05:28:43 +02:00
|
|
|
const buildSkills = getModelInvocableSkills(skills, "build");
|
|
|
|
|
expect(buildSkills).toHaveLength(2);
|
|
|
|
|
expect(buildSkills.some((s) => s.name === "build-skill")).toBe(true);
|
|
|
|
|
expect(buildSkills.some((s) => s.name === "universal-skill")).toBe(true);
|
|
|
|
|
expect(buildSkills.some((s) => s.name === "review-skill")).toBe(false);
|
|
|
|
|
expect(buildSkills.some((s) => s.name === "user-only")).toBe(false);
|
|
|
|
|
});
|
2026-05-08 23:52:35 +02:00
|
|
|
|
2026-05-14 19:32:41 +02:00
|
|
|
test("getUserInvocableSkills_excludes_locked_and_non_canonical_bundled", () => {
|
2026-05-08 23:52:35 +02:00
|
|
|
createSkill("human-facing", { userInvocable: true });
|
|
|
|
|
createSkill("autoresearch", { userInvocable: false });
|
|
|
|
|
const badDir = join(tmpDir, ".agents", "skills", "droid-evolved");
|
|
|
|
|
mkdirSync(badDir, { recursive: true });
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(badDir, "SKILL.md"),
|
|
|
|
|
`---\nname: droid-evolved\ndescription: Workflow-only skill\nuser-invocable: true\n---\n\n# Invalid\n`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const visible = getUserInvocableSkills([
|
2026-05-09 03:28:24 +02:00
|
|
|
...loadSkills(tmpDir, { includeWorkflow: false }),
|
2026-05-08 23:52:35 +02:00
|
|
|
{
|
2026-05-14 19:32:41 +02:00
|
|
|
name: "create-skill",
|
2026-05-08 23:52:35 +02:00
|
|
|
source: "bundled",
|
|
|
|
|
valid: true,
|
|
|
|
|
userInvocable: true,
|
2026-05-09 03:28:24 +02:00
|
|
|
locked: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
2026-05-14 19:32:41 +02:00
|
|
|
name: "review",
|
2026-05-09 03:28:24 +02:00
|
|
|
source: "bundled",
|
|
|
|
|
valid: true,
|
|
|
|
|
userInvocable: true,
|
2026-05-14 19:32:41 +02:00
|
|
|
locked: false,
|
2026-05-09 03:28:24 +02:00
|
|
|
},
|
|
|
|
|
{
|
2026-05-14 19:32:41 +02:00
|
|
|
name: "bundled-locked-system",
|
|
|
|
|
source: "bundled",
|
2026-05-09 03:28:24 +02:00
|
|
|
valid: true,
|
2026-05-14 19:32:41 +02:00
|
|
|
userInvocable: true,
|
2026-05-09 03:28:24 +02:00
|
|
|
locked: true,
|
2026-05-08 23:52:35 +02:00
|
|
|
},
|
|
|
|
|
{
|
2026-05-14 19:32:41 +02:00
|
|
|
name: "project-sf-command-surface",
|
2026-05-08 23:52:35 +02:00
|
|
|
source: "project",
|
|
|
|
|
valid: true,
|
|
|
|
|
userInvocable: true,
|
2026-05-09 03:28:24 +02:00
|
|
|
locked: false,
|
2026-05-08 23:52:35 +02:00
|
|
|
},
|
|
|
|
|
]);
|
2026-05-14 19:32:41 +02:00
|
|
|
// Locked skills are invisible. Only create-skill is a default bundled
|
|
|
|
|
// user skill; project/user skills can still appear.
|
2026-05-09 03:28:24 +02:00
|
|
|
const names = visible.map((s) => s.name).sort();
|
2026-05-14 19:32:41 +02:00
|
|
|
expect(names).toContain("create-skill");
|
|
|
|
|
expect(names).not.toContain("review");
|
|
|
|
|
expect(names).toContain("project-sf-command-surface");
|
2026-05-09 03:28:24 +02:00
|
|
|
expect(names).not.toContain("bundled-locked-system");
|
2026-05-14 19:32:41 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("loadSkills_default_catalog_exposes_only_create_skill_as_bundled_user_skill", () => {
|
|
|
|
|
const visible = getUserInvocableSkills(
|
|
|
|
|
loadSkills(tmpDir, {
|
|
|
|
|
includeBundled: true,
|
|
|
|
|
}),
|
|
|
|
|
).filter((s) => s.source === "bundled");
|
|
|
|
|
|
|
|
|
|
expect(visible.map((s) => s.name)).toEqual(["create-skill"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("loadSkills_workflow_skills_are_hidden_system_records", () => {
|
|
|
|
|
const skills = loadSkills(tmpDir, {
|
|
|
|
|
includeBundled: false,
|
|
|
|
|
includeWorkflow: true,
|
|
|
|
|
});
|
|
|
|
|
const workflow = skills.filter((s) => s.source === "workflow");
|
|
|
|
|
const visible = getUserInvocableSkills(skills);
|
|
|
|
|
|
|
|
|
|
expect(workflow.length).toBeGreaterThan(0);
|
|
|
|
|
expect(workflow.every((s) => s.valid)).toBe(true);
|
|
|
|
|
expect(workflow.every((s) => s.userInvocable === false)).toBe(true);
|
|
|
|
|
expect(workflow.some((s) => s.name === "sf-repo-orientation")).toBe(true);
|
|
|
|
|
expect(visible.some((s) => s.source === "workflow")).toBe(false);
|
2026-05-09 03:28:24 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("buildSkillRecord_sets_locked_from_frontmatter", () => {
|
2026-05-10 11:28:01 +02:00
|
|
|
const locked = buildSkillRecord(
|
|
|
|
|
"/p",
|
|
|
|
|
{ name: "x", description: "y", locked: true },
|
|
|
|
|
"",
|
|
|
|
|
);
|
2026-05-09 03:28:24 +02:00
|
|
|
expect(locked.locked).toBe(true);
|
2026-05-10 11:28:01 +02:00
|
|
|
const unlocked = buildSkillRecord(
|
|
|
|
|
"/p",
|
|
|
|
|
{ name: "x", description: "y" },
|
|
|
|
|
"",
|
|
|
|
|
);
|
2026-05-09 03:28:24 +02:00
|
|
|
expect(unlocked.locked).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-14 19:32:41 +02:00
|
|
|
test("project_skill_can_use_name_that_matches_hidden_workflow_pattern", () => {
|
|
|
|
|
// Hidden workflow patterns are product-owned and not part of the user
|
|
|
|
|
// skill catalog, so `.agents/skills/observe-first` remains a normal
|
|
|
|
|
// project skill name.
|
|
|
|
|
createSkill("observe-first");
|
2026-05-09 03:28:24 +02:00
|
|
|
|
2026-05-14 19:32:41 +02:00
|
|
|
const skills = loadSkills(tmpDir, { includeBundled: false });
|
2026-05-09 03:28:24 +02:00
|
|
|
const observeFirst = skills.filter((s) => s.name === "observe-first");
|
2026-05-14 19:32:41 +02:00
|
|
|
|
2026-05-09 03:28:24 +02:00
|
|
|
expect(observeFirst).toHaveLength(1);
|
2026-05-14 19:32:41 +02:00
|
|
|
expect(observeFirst[0].source).toBe("project");
|
|
|
|
|
expect(observeFirst[0].locked).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("skill preference resolution", () => {
|
|
|
|
|
test("getSkillSearchDirs_prefers_project_over_user_for_bare_names", () => {
|
|
|
|
|
const dirs = getSkillSearchDirs("/repo");
|
|
|
|
|
|
|
|
|
|
expect(dirs.map((d) => d.method).slice(0, 2)).toEqual([
|
|
|
|
|
"project-skill",
|
|
|
|
|
"user-skill",
|
|
|
|
|
]);
|
|
|
|
|
expect(dirs[0].dir).toBe(join("/repo", ".agents", "skills"));
|
|
|
|
|
expect(dirs.some((d) => d.dir.includes(".claude"))).toBe(false);
|
2026-05-08 23:52:35 +02:00
|
|
|
});
|
2026-05-08 05:28:43 +02:00
|
|
|
});
|