mirror of
https://github.com/gethomepage/homepage.git
synced 2026-02-06 07:57:05 +00:00
Chore: homepage tests (#6278)
This commit is contained in:
parent
7d019185a3
commit
872a3600aa
21
.codecov.yml
Normal file
21
.codecov.yml
Normal file
@ -0,0 +1,21 @@
|
||||
codecov:
|
||||
require_ci_to_pass: true
|
||||
|
||||
coverage:
|
||||
precision: 2
|
||||
round: down
|
||||
range: "0...100"
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: 100%
|
||||
threshold: 25%
|
||||
patch:
|
||||
default:
|
||||
target: 100%
|
||||
threshold: 25%
|
||||
|
||||
comment:
|
||||
layout: "reach,diff,flags,files"
|
||||
behavior: default
|
||||
require_changes: false
|
||||
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -35,6 +35,7 @@ What type of change does your PR introduce to Homepage?
|
||||
## Checklist:
|
||||
|
||||
- [ ] If applicable, I have added corresponding documentation changes.
|
||||
- [ ] If applicable, I have added or updated tests for new features and bug fixes.
|
||||
- [ ] If applicable, I have reviewed the [feature / enhancement](https://gethomepage.dev/more/development/#new-feature-guidelines) and / or [service widget guidelines](https://gethomepage.dev/more/development/#service-widget-guidelines).
|
||||
- [ ] I have checked that all code style checks pass using [pre-commit hooks](https://gethomepage.dev/more/development/#code-formatting-with-pre-commit-hooks) and [linting checks](https://gethomepage.dev/more/development/#code-linting).
|
||||
- [ ] If applicable, I have tested my code for new features & regressions on both mobile & desktop devices, using the latest version of major browsers.
|
||||
|
||||
37
.github/workflows/test.yml
vendored
Normal file
37
.github/workflows/test.yml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
vitest:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Run Vitest directly so `--shard` is parsed as an option
|
||||
- run: pnpm -s exec vitest run --coverage --shard ${{ matrix.shard }}/4 --pool forks
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./coverage/lcov.info
|
||||
flags: vitest,shard-${{ matrix.shard }}
|
||||
name: vitest-shard-${{ matrix.shard }}
|
||||
fail_ci_if_error: true
|
||||
@ -33,6 +33,32 @@ Once dependencies have been installed you can lint your code with
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Homepage uses [Vitest](https://vitest.dev/) for unit and component tests.
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Run the test suite with coverage:
|
||||
|
||||
```bash
|
||||
pnpm test:coverage
|
||||
```
|
||||
|
||||
### What tests to include
|
||||
|
||||
- New or updated widgets should generally include a component test near the widget component (for example `src/widgets/<widget>/component.test.jsx`) that covers realistic behavior: loading/placeholder state, error state, and a representative "happy path" render.
|
||||
- If you add or change a widget definition file (`src/widgets/<widget>/widget.js`), add/update its corresponding unit test (`src/widgets/<widget>/widget.test.js`) to cover the config/mapping behavior.
|
||||
- If your widget requires a custom proxy (`src/widgets/<widget>/proxy.js`), add a proxy unit test (`src/widgets/<widget>/proxy.test.js`) that validates:
|
||||
- request construction (URL, query params, headers/auth)
|
||||
- response mapping (what the widget consumes)
|
||||
- error pathways (upstream error, unexpected payloads)
|
||||
- Avoid placing test files under `src/pages/**` (Next.js treats files there as routes). Page tests should live under `src/__tests__/pages/**`.
|
||||
|
||||
## Code formatting with pre-commit hooks
|
||||
|
||||
To ensure a consistent style and formatting across the project source, the project utilizes Git [`pre-commit`](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) hooks to perform some formatting and linting before a commit is allowed.
|
||||
|
||||
@ -201,3 +201,18 @@ export default async function customProxyHandler(req, res, map) {
|
||||
```
|
||||
|
||||
Proxy handlers are a complex topic and require a good understanding of JavaScript and the Homepage codebase. If you are new to Homepage, we recommend using the built-in proxy handlers.
|
||||
|
||||
## Testing proxy handlers
|
||||
|
||||
Proxy handlers are a common source of regressions because they deal with authentication, request formatting, and sometimes odd upstream API behavior.
|
||||
|
||||
When you add a new proxy handler or custom widget proxy, include tests that focus on behavior:
|
||||
|
||||
- **Request construction:** the correct URL/path, query params, headers, and auth (and that secrets are not accidentally logged).
|
||||
- **Response mapping:** the payload shape expected by the widget/component (including optional/missing fields).
|
||||
- **Error handling:** upstream non-200s, invalid JSON, timeouts, and unexpected payloads should produce a predictable result.
|
||||
|
||||
Test locations:
|
||||
|
||||
- Shared handlers live in `src/utils/proxy/handlers/*.js` with tests alongside them (for example `src/utils/proxy/handlers/generic.test.js`).
|
||||
- Widget-specific proxies live in `src/widgets/<widget>/proxy.js` with tests in `src/widgets/<widget>/proxy.test.js`.
|
||||
|
||||
@ -65,5 +65,14 @@ export default defineConfig([
|
||||
],
|
||||
},
|
||||
},
|
||||
globalIgnores(["./config/", "./.venv/", "./.next/", "./site/"]),
|
||||
// Vitest tests often intentionally place imports after `vi.mock(...)` to ensure
|
||||
// modules under test see the mocked dependencies. `import/order` can't safely
|
||||
// auto-fix those cases, so disable it for test files.
|
||||
{
|
||||
files: ["src/**/*.test.{js,jsx}", "src/**/*.spec.{js,jsx}"],
|
||||
rules: {
|
||||
"import/order": "off",
|
||||
},
|
||||
},
|
||||
globalIgnores(["./config/", "./coverage/", "./.venv/", "./.next/", "./site/"]),
|
||||
]);
|
||||
|
||||
10
package.json
10
package.json
@ -8,6 +8,9 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest",
|
||||
"telemetry": "next telemetry disable"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -49,6 +52,9 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-config-next": "^15.5.11",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
@ -57,12 +63,14 @@
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.7.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"tailwind-scrollbar": "^4.0.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"osx-temperature-sensor": "^1.0.8"
|
||||
|
||||
1522
pnpm-lock.yaml
1522
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
37
src/__tests__/pages/_app.test.jsx
Normal file
37
src/__tests__/pages/_app.test.jsx
Normal file
@ -0,0 +1,37 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Next's Head implementation relies on internal Next contexts; stub it for unit tests.
|
||||
vi.mock("next/head", () => ({
|
||||
default: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("utils/contexts/color", () => ({
|
||||
ColorProvider: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("utils/contexts/theme", () => ({
|
||||
ThemeProvider: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("utils/contexts/settings", () => ({
|
||||
SettingsProvider: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("utils/contexts/tab", () => ({
|
||||
TabProvider: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import App from "pages/_app.jsx";
|
||||
|
||||
describe("pages/_app", () => {
|
||||
it("renders the active page component with pageProps", () => {
|
||||
function Page({ message }) {
|
||||
return <div>msg:{message}</div>;
|
||||
}
|
||||
|
||||
render(<App Component={Page} pageProps={{ message: "hello" }} />);
|
||||
|
||||
expect(screen.getByText("msg:hello")).toBeInTheDocument();
|
||||
expect(document.querySelector('meta[name="viewport"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
24
src/__tests__/pages/_document.test.jsx
Normal file
24
src/__tests__/pages/_document.test.jsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next/document", () => ({
|
||||
Html: ({ children }) => <div data-testid="html">{children}</div>,
|
||||
Head: ({ children }) => <div data-testid="head">{children}</div>,
|
||||
Main: () => <main data-testid="main" />,
|
||||
NextScript: () => <script data-testid="nextscript" />,
|
||||
}));
|
||||
|
||||
import Document from "pages/_document.jsx";
|
||||
|
||||
describe("pages/_document", () => {
|
||||
it("renders the PWA meta + custom css links", () => {
|
||||
const html = renderToStaticMarkup(<Document />);
|
||||
|
||||
expect(html).toContain('meta name="mobile-web-app-capable" content="yes"');
|
||||
expect(html).toContain('link rel="manifest" href="/site.webmanifest?v=4"');
|
||||
expect(html).toContain('link rel="preload" href="/api/config/custom.css" as="style"');
|
||||
expect(html).toContain('link rel="stylesheet" href="/api/config/custom.css"');
|
||||
expect(html).toContain('data-testid="main"');
|
||||
expect(html).toContain('data-testid="nextscript"');
|
||||
});
|
||||
});
|
||||
30
src/__tests__/pages/api/bookmarks.test.js
Normal file
30
src/__tests__/pages/api/bookmarks.test.js
Normal file
@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { bookmarksResponse } = vi.hoisted(() => ({
|
||||
bookmarksResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/api-response", () => ({
|
||||
bookmarksResponse,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/bookmarks";
|
||||
|
||||
describe("pages/api/bookmarks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns bookmarksResponse()", async () => {
|
||||
bookmarksResponse.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
87
src/__tests__/pages/api/config/[path].test.js
Normal file
87
src/__tests__/pages/api/config/[path].test.js
Normal file
@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { fs, config, logger } = vi.hoisted(() => ({
|
||||
fs: {
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
},
|
||||
config: {
|
||||
CONF_DIR: "/conf",
|
||||
},
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("fs", () => ({
|
||||
default: fs,
|
||||
...fs,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => config);
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/config/[path]";
|
||||
|
||||
describe("pages/api/config/[path]", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 422 for unsupported files", async () => {
|
||||
const req = { query: { path: "not-supported.txt" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(422);
|
||||
});
|
||||
|
||||
it("returns empty content when the file doesn't exist", async () => {
|
||||
fs.existsSync.mockReturnValueOnce(false);
|
||||
|
||||
const req = { query: { path: "custom.css" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.headers["Content-Type"]).toBe("text/css");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("");
|
||||
});
|
||||
|
||||
it("returns file content when the file exists", async () => {
|
||||
fs.existsSync.mockReturnValueOnce(true);
|
||||
fs.readFileSync.mockReturnValueOnce("body{}");
|
||||
|
||||
const req = { query: { path: "custom.js" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.headers["Content-Type"]).toBe("text/javascript");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("body{}");
|
||||
});
|
||||
|
||||
it("logs and returns 500 when reading the file throws", async () => {
|
||||
fs.existsSync.mockReturnValueOnce(true);
|
||||
fs.readFileSync.mockImplementationOnce(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
const req = { query: { path: "custom.css" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toBe("Internal Server Error");
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
153
src/__tests__/pages/api/docker/stats/[...service].test.js
Normal file
153
src/__tests__/pages/api/docker/stats/[...service].test.js
Normal file
@ -0,0 +1,153 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { state, DockerCtor, getDockerArguments, logger } = vi.hoisted(() => {
|
||||
const state = {
|
||||
docker: null,
|
||||
dockerArgs: { conn: { socketPath: "/var/run/docker.sock" }, swarm: false },
|
||||
};
|
||||
|
||||
function DockerCtor() {
|
||||
return state.docker;
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
DockerCtor,
|
||||
getDockerArguments: vi.fn(() => state.dockerArgs),
|
||||
logger: { error: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("dockerode", () => ({
|
||||
default: DockerCtor,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/docker", () => ({
|
||||
default: getDockerArguments,
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/docker/stats/[...service]";
|
||||
|
||||
describe("pages/api/docker/stats/[...service]", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.dockerArgs = { conn: { socketPath: "/var/run/docker.sock" }, swarm: false };
|
||||
state.docker = {
|
||||
listContainers: vi.fn(),
|
||||
getContainer: vi.fn(),
|
||||
listTasks: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it("returns 400 when container name/server params are missing", async () => {
|
||||
const req = { query: { service: [] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "docker query parameters are required" });
|
||||
});
|
||||
|
||||
it("returns 500 when docker returns a non-array containers payload", async () => {
|
||||
state.docker.listContainers.mockResolvedValue(Buffer.from("bad"));
|
||||
|
||||
const req = { query: { service: ["c", "local"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "query failed" });
|
||||
});
|
||||
|
||||
it("returns stats for an existing container", async () => {
|
||||
state.docker.listContainers.mockResolvedValue([{ Names: ["/myapp"], Id: "cid1" }]);
|
||||
const containerStats = { cpu_stats: { cpu_usage: { total_usage: 1 } } };
|
||||
state.docker.getContainer.mockReturnValue({
|
||||
stats: vi.fn().mockResolvedValue(containerStats),
|
||||
});
|
||||
|
||||
const req = { query: { service: ["myapp", "local"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ stats: containerStats });
|
||||
});
|
||||
|
||||
it("uses swarm tasks to locate a container and reports a friendly error when stats cannot be retrieved", async () => {
|
||||
state.dockerArgs.swarm = true;
|
||||
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]);
|
||||
state.docker.listTasks.mockResolvedValue([
|
||||
{ Status: { ContainerStatus: { ContainerID: "local1" } } },
|
||||
{ Status: { ContainerStatus: { ContainerID: "remote1" } } },
|
||||
]);
|
||||
state.docker.getContainer.mockReturnValue({
|
||||
stats: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
});
|
||||
|
||||
const req = { query: { service: ["svc", "local"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ error: "Unable to retrieve stats" });
|
||||
});
|
||||
|
||||
it("returns stats for a swarm task container when present locally", async () => {
|
||||
state.dockerArgs.swarm = true;
|
||||
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]);
|
||||
state.docker.listTasks.mockResolvedValue([{ Status: { ContainerStatus: { ContainerID: "local1" } } }]);
|
||||
|
||||
const containerStats = { cpu_stats: { cpu_usage: { total_usage: 2 } } };
|
||||
state.docker.getContainer.mockReturnValue({
|
||||
stats: vi.fn().mockResolvedValue(containerStats),
|
||||
});
|
||||
|
||||
const req = { query: { service: ["svc", "local"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ stats: containerStats });
|
||||
});
|
||||
|
||||
it("returns 404 when no container or swarm task is found", async () => {
|
||||
state.dockerArgs.swarm = true;
|
||||
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]);
|
||||
state.docker.listTasks.mockResolvedValue([]);
|
||||
|
||||
const req = { query: { service: ["missing", "local"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: "not found" });
|
||||
});
|
||||
|
||||
it("logs and returns 500 when the docker query throws", async () => {
|
||||
getDockerArguments.mockImplementationOnce(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
const req = { query: { service: ["myapp", "local"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: { message: "boom" } });
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
211
src/__tests__/pages/api/docker/status/[...service].test.js
Normal file
211
src/__tests__/pages/api/docker/status/[...service].test.js
Normal file
@ -0,0 +1,211 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { state, DockerCtor, getDockerArguments, logger } = vi.hoisted(() => {
|
||||
const state = {
|
||||
docker: null,
|
||||
dockerCtorArgs: [],
|
||||
dockerArgs: { conn: { socketPath: "/var/run/docker.sock" }, swarm: false },
|
||||
};
|
||||
|
||||
function DockerCtor(conn) {
|
||||
state.dockerCtorArgs.push(conn);
|
||||
return state.docker;
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
DockerCtor,
|
||||
getDockerArguments: vi.fn(() => state.dockerArgs),
|
||||
logger: { error: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("dockerode", () => ({
|
||||
default: DockerCtor,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/docker", () => ({
|
||||
default: getDockerArguments,
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/docker/status/[...service]";
|
||||
|
||||
describe("pages/api/docker/status/[...service]", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.dockerCtorArgs.length = 0;
|
||||
state.dockerArgs = { conn: { socketPath: "/var/run/docker.sock" }, swarm: false };
|
||||
state.docker = {
|
||||
listContainers: vi.fn(),
|
||||
getContainer: vi.fn(),
|
||||
getService: vi.fn(),
|
||||
listTasks: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it("returns 400 when container name/server params are missing", async () => {
|
||||
const req = { query: { service: [] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "docker query parameters are required" });
|
||||
});
|
||||
|
||||
it("returns 500 when docker returns a non-array containers payload", async () => {
|
||||
state.docker.listContainers.mockResolvedValue(Buffer.from("bad"));
|
||||
|
||||
const req = { query: { service: ["c", "local"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "query failed" });
|
||||
});
|
||||
|
||||
it("inspects an existing container and returns status + health", async () => {
|
||||
state.docker.listContainers.mockResolvedValue([{ Names: ["/myapp"], Id: "cid1" }]);
|
||||
state.docker.getContainer.mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue({ State: { Status: "running", Health: { Status: "healthy" } } }),
|
||||
});
|
||||
|
||||
const req = { query: { service: ["myapp", "local"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(getDockerArguments).toHaveBeenCalledWith("local");
|
||||
expect(state.dockerCtorArgs).toHaveLength(1);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ status: "running", health: "healthy" });
|
||||
});
|
||||
|
||||
it("returns 404 when container does not exist and swarm is disabled", async () => {
|
||||
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]);
|
||||
|
||||
const req = { query: { service: ["missing", "local"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ status: "not found" });
|
||||
});
|
||||
|
||||
it("reports replicated swarm service status based on desired replicas", async () => {
|
||||
state.dockerArgs.swarm = true;
|
||||
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]);
|
||||
state.docker.getService.mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Replicated: { Replicas: "2" } } } }),
|
||||
});
|
||||
state.docker.listTasks.mockResolvedValue([{ Status: {} }, { Status: {} }]);
|
||||
|
||||
const req = { query: { service: ["svc", "local"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ status: "running 2/2" });
|
||||
});
|
||||
|
||||
it("reports partial status for replicated services with fewer running tasks", async () => {
|
||||
state.dockerArgs.swarm = true;
|
||||
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]);
|
||||
state.docker.getService.mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Replicated: { Replicas: "3" } } } }),
|
||||
});
|
||||
state.docker.listTasks.mockResolvedValue([{ Status: {} }]);
|
||||
|
||||
const req = { query: { service: ["svc", "local"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ status: "partial 1/3" });
|
||||
});
|
||||
|
||||
it("handles global services by inspecting a local task container when possible", async () => {
|
||||
state.dockerArgs.swarm = true;
|
||||
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]);
|
||||
state.docker.getService.mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Global: {} } } }),
|
||||
});
|
||||
state.docker.listTasks.mockResolvedValue([
|
||||
{ Status: { ContainerStatus: { ContainerID: "local1" }, State: "running" } },
|
||||
]);
|
||||
state.docker.getContainer.mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue({ State: { Status: "running", Health: { Status: "unhealthy" } } }),
|
||||
});
|
||||
|
||||
const req = { query: { service: ["svc", "local"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ status: "running", health: "unhealthy" });
|
||||
});
|
||||
|
||||
it("falls back to task status when global service container inspect fails", async () => {
|
||||
state.dockerArgs.swarm = true;
|
||||
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]);
|
||||
state.docker.getService.mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Global: {} } } }),
|
||||
});
|
||||
state.docker.listTasks.mockResolvedValue([
|
||||
{ Status: { ContainerStatus: { ContainerID: "local1" }, State: "pending" } },
|
||||
]);
|
||||
state.docker.getContainer.mockReturnValue({
|
||||
inspect: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
});
|
||||
|
||||
const req = { query: { service: ["svc", "local"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ status: "pending" });
|
||||
});
|
||||
|
||||
it("returns 404 when swarm is enabled but the service does not exist", async () => {
|
||||
state.dockerArgs.swarm = true;
|
||||
state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]);
|
||||
state.docker.getService.mockReturnValue({
|
||||
inspect: vi.fn().mockRejectedValue(new Error("not found")),
|
||||
});
|
||||
|
||||
const req = { query: { service: ["svc", "local"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ status: "not found" });
|
||||
});
|
||||
|
||||
it("logs and returns 500 when the docker query throws", async () => {
|
||||
getDockerArguments.mockImplementationOnce(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
const req = { query: { service: ["svc", "local"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: { message: "boom" } });
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
64
src/__tests__/pages/api/hash.test.js
Normal file
64
src/__tests__/pages/api/hash.test.js
Normal file
@ -0,0 +1,64 @@
|
||||
import { createHash } from "crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
function sha256(input) {
|
||||
return createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
|
||||
const { readFileSync, checkAndCopyConfig, CONF_DIR } = vi.hoisted(() => ({
|
||||
readFileSync: vi.fn(),
|
||||
checkAndCopyConfig: vi.fn(),
|
||||
CONF_DIR: "/conf",
|
||||
}));
|
||||
|
||||
vi.mock("fs", () => ({
|
||||
readFileSync,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
default: checkAndCopyConfig,
|
||||
CONF_DIR,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/hash";
|
||||
|
||||
describe("pages/api/hash", () => {
|
||||
const originalBuildTime = process.env.HOMEPAGE_BUILDTIME;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.HOMEPAGE_BUILDTIME = originalBuildTime;
|
||||
});
|
||||
|
||||
it("returns a combined sha256 hash of known config files and build time", async () => {
|
||||
process.env.HOMEPAGE_BUILDTIME = "build-1";
|
||||
|
||||
// Return deterministic contents based on file name.
|
||||
readFileSync.mockImplementation((filePath) => {
|
||||
const name = filePath.split("/").pop();
|
||||
return `content:${name}`;
|
||||
});
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
const configs = [
|
||||
"docker.yaml",
|
||||
"settings.yaml",
|
||||
"services.yaml",
|
||||
"bookmarks.yaml",
|
||||
"widgets.yaml",
|
||||
"custom.css",
|
||||
"custom.js",
|
||||
];
|
||||
const hashes = configs.map((c) => sha256(`content:${c}`));
|
||||
const expected = sha256(hashes.join("") + "build-1");
|
||||
|
||||
expect(checkAndCopyConfig).toHaveBeenCalled();
|
||||
expect(res.body).toEqual({ hash: expected });
|
||||
});
|
||||
});
|
||||
16
src/__tests__/pages/api/healthcheck.test.js
Normal file
16
src/__tests__/pages/api/healthcheck.test.js
Normal file
@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
import handler from "pages/api/healthcheck";
|
||||
|
||||
describe("pages/api/healthcheck", () => {
|
||||
it("returns 'up'", () => {
|
||||
const req = {};
|
||||
const res = createMockRes();
|
||||
|
||||
handler(req, res);
|
||||
|
||||
expect(res.body).toBe("up");
|
||||
});
|
||||
});
|
||||
210
src/__tests__/pages/api/kubernetes/stats/[...service].test.js
Normal file
210
src/__tests__/pages/api/kubernetes/stats/[...service].test.js
Normal file
@ -0,0 +1,210 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { getKubeConfig, coreApi, metricsApi, MetricsCtor, logger } = vi.hoisted(() => {
|
||||
const metricsApi = {
|
||||
getPodMetrics: vi.fn(),
|
||||
};
|
||||
|
||||
function MetricsCtor() {
|
||||
return metricsApi;
|
||||
}
|
||||
|
||||
return {
|
||||
getKubeConfig: vi.fn(),
|
||||
coreApi: { listNamespacedPod: vi.fn() },
|
||||
metricsApi,
|
||||
MetricsCtor,
|
||||
logger: { error: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@kubernetes/client-node", () => ({
|
||||
CoreV1Api: function CoreV1Api() {},
|
||||
Metrics: MetricsCtor,
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/kubernetes", () => ({
|
||||
getKubeConfig,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/kubernetes/stats/[...service]";
|
||||
|
||||
describe("pages/api/kubernetes/stats/[...service]", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getKubeConfig.mockReturnValue({
|
||||
makeApiClient: () => coreApi,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 when namespace/appName params are missing", async () => {
|
||||
const req = { query: { service: [] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "kubernetes query parameters are required" });
|
||||
});
|
||||
|
||||
it("returns 500 when kubernetes is not configured", async () => {
|
||||
getKubeConfig.mockReturnValue(null);
|
||||
|
||||
const req = { query: { service: ["default", "app"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "No kubernetes configuration" });
|
||||
});
|
||||
|
||||
it("returns 500 when listNamespacedPod fails", async () => {
|
||||
coreApi.listNamespacedPod.mockRejectedValue({ statusCode: 500, body: "nope", response: "nope" });
|
||||
|
||||
const req = { query: { service: ["default", "app"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "Error communicating with kubernetes" });
|
||||
});
|
||||
|
||||
it("returns 404 when no pods match the selector", async () => {
|
||||
coreApi.listNamespacedPod.mockResolvedValue({ items: [] });
|
||||
|
||||
const req = { query: { service: ["default", "app"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({
|
||||
error: "no pods found with namespace=default and labelSelector=app.kubernetes.io/name=app",
|
||||
});
|
||||
});
|
||||
|
||||
it("computes limits even when metrics are missing (404 from metrics server)", async () => {
|
||||
coreApi.listNamespacedPod.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: "pod-a" },
|
||||
spec: {
|
||||
containers: [
|
||||
{ resources: { limits: { cpu: "500m", memory: "1Gi" } } },
|
||||
{ resources: { limits: { cpu: "250m" } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
metricsApi.getPodMetrics.mockRejectedValue({ statusCode: 404, body: "no metrics", response: "no metrics" });
|
||||
|
||||
const req = { query: { service: ["default", "app"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
stats: {
|
||||
mem: 0,
|
||||
cpu: 0,
|
||||
cpuLimit: 0.75,
|
||||
memLimit: 1000000000,
|
||||
cpuUsage: 0,
|
||||
memUsage: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("logs when metrics lookup fails with a non-404 error and still returns computed limits", async () => {
|
||||
coreApi.listNamespacedPod.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: "pod-a" },
|
||||
spec: {
|
||||
containers: [{ resources: { limits: { cpu: "500m", memory: "1Gi" } } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
metricsApi.getPodMetrics.mockRejectedValue({ statusCode: 500, body: "boom", response: "boom" });
|
||||
|
||||
const req = { query: { service: ["default", "app"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.stats.cpuLimit).toBe(0.5);
|
||||
expect(res.body.stats.memLimit).toBe(1000000000);
|
||||
expect(res.body.stats.cpu).toBe(0);
|
||||
expect(res.body.stats.mem).toBe(0);
|
||||
});
|
||||
|
||||
it("aggregates usage for matched pods and reports percent usage", async () => {
|
||||
coreApi.listNamespacedPod.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: "pod-a" },
|
||||
spec: { containers: [{ resources: { limits: { cpu: "1000m", memory: "2Gi" } } }] },
|
||||
},
|
||||
{
|
||||
metadata: { name: "pod-b" },
|
||||
spec: { containers: [{ resources: { limits: { cpu: "500m", memory: "1Gi" } } }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
metricsApi.getPodMetrics.mockResolvedValue({
|
||||
items: [
|
||||
// includes a non-selected pod, should be ignored
|
||||
{ metadata: { name: "other" }, containers: [{ usage: { cpu: "100m", memory: "10Mi" } }] },
|
||||
{
|
||||
metadata: { name: "pod-a" },
|
||||
containers: [{ usage: { cpu: "250m", memory: "100Mi" } }, { usage: { cpu: "250m", memory: "100Mi" } }],
|
||||
},
|
||||
{ metadata: { name: "pod-b" }, containers: [{ usage: { cpu: "500m", memory: "1Gi" } }] },
|
||||
],
|
||||
});
|
||||
|
||||
const req = { query: { service: ["default", "app"], podSelector: "app=test" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
const { stats } = res.body;
|
||||
expect(stats.cpuLimit).toBe(1.5);
|
||||
expect(stats.memLimit).toBe(3000000000);
|
||||
expect(stats.cpu).toBeCloseTo(1.0, 5);
|
||||
expect(stats.mem).toBe(1200000000);
|
||||
expect(stats.cpuUsage).toBeCloseTo((100 * 1.0) / 1.5, 5);
|
||||
expect(stats.memUsage).toBeCloseTo((100 * 1200000000) / 3000000000, 5);
|
||||
});
|
||||
|
||||
it("returns 500 when an unexpected error is thrown", async () => {
|
||||
getKubeConfig.mockImplementationOnce(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
const req = { query: { service: ["default", "app"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "unknown error" });
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
121
src/__tests__/pages/api/kubernetes/status/[...service].test.js
Normal file
121
src/__tests__/pages/api/kubernetes/status/[...service].test.js
Normal file
@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { getKubeConfig, coreApi, logger } = vi.hoisted(() => ({
|
||||
getKubeConfig: vi.fn(),
|
||||
coreApi: { listNamespacedPod: vi.fn() },
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/kubernetes", () => ({
|
||||
getKubeConfig,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/kubernetes/status/[...service]";
|
||||
|
||||
describe("pages/api/kubernetes/status/[...service]", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getKubeConfig.mockReturnValue({
|
||||
makeApiClient: () => coreApi,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 when namespace/appName params are missing", async () => {
|
||||
const req = { query: { service: [] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "kubernetes query parameters are required" });
|
||||
});
|
||||
|
||||
it("returns 500 when kubernetes is not configured", async () => {
|
||||
getKubeConfig.mockReturnValue(null);
|
||||
|
||||
const req = { query: { service: ["default", "app"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "No kubernetes configuration" });
|
||||
});
|
||||
|
||||
it("returns 500 when listNamespacedPod fails", async () => {
|
||||
coreApi.listNamespacedPod.mockRejectedValue({ statusCode: 500, body: "nope", response: "nope" });
|
||||
|
||||
const req = { query: { service: ["default", "app"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "Error communicating with kubernetes" });
|
||||
});
|
||||
|
||||
it("returns 404 when no pods match the selector", async () => {
|
||||
coreApi.listNamespacedPod.mockResolvedValue({ items: [] });
|
||||
|
||||
const req = { query: { service: ["default", "app"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ status: "not found" });
|
||||
});
|
||||
|
||||
it("returns partial when some pods are ready but not all", async () => {
|
||||
coreApi.listNamespacedPod.mockResolvedValue({
|
||||
items: [{ status: { phase: "Running" } }, { status: { phase: "Pending" } }],
|
||||
});
|
||||
|
||||
const req = { query: { service: ["default", "app"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ status: "partial" });
|
||||
});
|
||||
|
||||
it("returns running when all pods are ready", async () => {
|
||||
coreApi.listNamespacedPod.mockResolvedValue({
|
||||
items: [{ status: { phase: "Running" } }, { status: { phase: "Succeeded" } }],
|
||||
});
|
||||
|
||||
const req = { query: { service: ["default", "app"], podSelector: "app=test" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(coreApi.listNamespacedPod).toHaveBeenCalledWith({
|
||||
namespace: "default",
|
||||
labelSelector: "app=test",
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ status: "running" });
|
||||
});
|
||||
|
||||
it("returns 500 when an unexpected error is thrown", async () => {
|
||||
getKubeConfig.mockImplementationOnce(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
const req = { query: { service: ["default", "app"] } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "unknown error" });
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
80
src/__tests__/pages/api/ping.test.js
Normal file
80
src/__tests__/pages/api/ping.test.js
Normal file
@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { getServiceItem, ping, logger } = vi.hoisted(() => ({
|
||||
getServiceItem: vi.fn(),
|
||||
ping: { probe: vi.fn() },
|
||||
logger: { debug: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
getServiceItem,
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("ping", () => ({
|
||||
promise: ping,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/ping";
|
||||
|
||||
describe("pages/api/ping", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 400 when service item isn't found", async () => {
|
||||
getServiceItem.mockResolvedValueOnce(null);
|
||||
|
||||
const req = { query: { groupName: "g", serviceName: "s" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toContain("Unable to find service");
|
||||
});
|
||||
|
||||
it("returns 400 when ping host isn't configured", async () => {
|
||||
getServiceItem.mockResolvedValueOnce({ ping: "" });
|
||||
|
||||
const req = { query: { groupName: "g", serviceName: "s" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe("No ping host given");
|
||||
});
|
||||
|
||||
it("pings the hostname extracted from a URL", async () => {
|
||||
getServiceItem.mockResolvedValueOnce({ ping: "http://example.com:1234/path" });
|
||||
ping.probe.mockResolvedValueOnce({ alive: true });
|
||||
|
||||
const req = { query: { groupName: "g", serviceName: "s" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(ping.probe).toHaveBeenCalledWith("example.com");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ alive: true });
|
||||
});
|
||||
|
||||
it("returns 400 when ping throws", async () => {
|
||||
getServiceItem.mockResolvedValueOnce({ ping: "example.com" });
|
||||
ping.probe.mockRejectedValueOnce(new Error("nope"));
|
||||
|
||||
const req = { query: { groupName: "g", serviceName: "s" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toContain("Error attempting ping");
|
||||
});
|
||||
});
|
||||
148
src/__tests__/pages/api/proxmox/stats/[...service].test.js
Normal file
148
src/__tests__/pages/api/proxmox/stats/[...service].test.js
Normal file
@ -0,0 +1,148 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { getProxmoxConfig, httpProxy, logger } = vi.hoisted(() => ({
|
||||
getProxmoxConfig: vi.fn(),
|
||||
httpProxy: vi.fn(),
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/proxmox", () => ({
|
||||
getProxmoxConfig,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/proxmox/stats/[...service]";
|
||||
|
||||
describe("pages/api/proxmox/stats/[...service]", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 400 when node param is missing", async () => {
|
||||
const req = { query: { service: [], type: "qemu" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Proxmox node parameter is required" });
|
||||
});
|
||||
|
||||
it("returns 500 when proxmox config is missing", async () => {
|
||||
getProxmoxConfig.mockReturnValue(null);
|
||||
|
||||
const req = { query: { service: ["pve", "100"], type: "qemu" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "Proxmox server configuration not found" });
|
||||
});
|
||||
|
||||
it("returns 400 when node config is missing and legacy credentials are not present", async () => {
|
||||
getProxmoxConfig.mockReturnValue({ other: { url: "http://x", token: "t", secret: "s" } });
|
||||
|
||||
const req = { query: { service: ["pve", "100"], type: "qemu" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual(
|
||||
expect.objectContaining({
|
||||
error: expect.stringContaining("Proxmox config not found for the specified node"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns status/cpu/mem for a successful Proxmox response using per-node credentials", async () => {
|
||||
getProxmoxConfig.mockReturnValue({
|
||||
pve: { url: "http://pve", token: "tok", secret: "sec" },
|
||||
});
|
||||
httpProxy.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { status: "running", cpu: 0.2, mem: 123 } })),
|
||||
]);
|
||||
|
||||
const req = { query: { service: ["pve", "100"], type: "qemu" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledWith("http://pve/api2/json/nodes/pve/qemu/100/status/current", {
|
||||
method: "GET",
|
||||
headers: { Authorization: "PVEAPIToken=tok=sec" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ status: "running", cpu: 0.2, mem: 123 });
|
||||
});
|
||||
|
||||
it("falls back to legacy top-level credentials when no node block exists", async () => {
|
||||
getProxmoxConfig.mockReturnValue({ url: "http://pve", token: "tok", secret: "sec" });
|
||||
httpProxy.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { cpu: 0.1, mem: 1 } })),
|
||||
]);
|
||||
|
||||
const req = { query: { service: ["pve", "100"], type: "lxc" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledWith("http://pve/api2/json/nodes/pve/lxc/100/status/current", expect.any(Object));
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ status: "unknown", cpu: 0.1, mem: 1 });
|
||||
});
|
||||
|
||||
it("returns a non-200 status when Proxmox responds with an error", async () => {
|
||||
getProxmoxConfig.mockReturnValue({ url: "http://pve", token: "tok", secret: "sec" });
|
||||
httpProxy.mockResolvedValueOnce([401, "application/json", Buffer.from(JSON.stringify({ error: "no" }))]);
|
||||
|
||||
const req = { query: { service: ["pve", "100"], type: "qemu" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body).toEqual({ error: "Failed to fetch Proxmox qemu status" });
|
||||
});
|
||||
|
||||
it("returns 500 when the Proxmox response is missing expected data", async () => {
|
||||
getProxmoxConfig.mockReturnValue({ url: "http://pve", token: "tok", secret: "sec" });
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({}))]);
|
||||
|
||||
const req = { query: { service: ["pve", "100"], type: "qemu" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "Invalid response from Proxmox API" });
|
||||
});
|
||||
|
||||
it("logs and returns 500 when an unexpected error occurs", async () => {
|
||||
getProxmoxConfig.mockReturnValue({ url: "http://pve", token: "tok", secret: "sec" });
|
||||
httpProxy.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const req = { query: { service: ["pve", "100"], type: "qemu" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "Failed to fetch Proxmox status" });
|
||||
});
|
||||
});
|
||||
46
src/__tests__/pages/api/releases.test.js
Normal file
46
src/__tests__/pages/api/releases.test.js
Normal file
@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { cachedRequest, logger } = vi.hoisted(() => ({
|
||||
cachedRequest: vi.fn(),
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
cachedRequest,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/releases";
|
||||
|
||||
describe("pages/api/releases", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns cached GitHub releases", async () => {
|
||||
cachedRequest.mockResolvedValueOnce([{ tag_name: "v1" }]);
|
||||
|
||||
const req = {};
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body).toEqual([{ tag_name: "v1" }]);
|
||||
});
|
||||
|
||||
it("returns [] when cachedRequest throws", async () => {
|
||||
cachedRequest.mockRejectedValueOnce(new Error("nope"));
|
||||
|
||||
const req = {};
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
29
src/__tests__/pages/api/revalidate.test.js
Normal file
29
src/__tests__/pages/api/revalidate.test.js
Normal file
@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
import handler from "pages/api/revalidate";
|
||||
|
||||
describe("pages/api/revalidate", () => {
|
||||
it("revalidates and returns {revalidated:true}", async () => {
|
||||
const req = {};
|
||||
const res = createMockRes();
|
||||
res.revalidate = vi.fn().mockResolvedValueOnce(undefined);
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.revalidate).toHaveBeenCalledWith("/");
|
||||
expect(res.body).toEqual({ revalidated: true });
|
||||
});
|
||||
|
||||
it("returns 500 when revalidate throws", async () => {
|
||||
const req = {};
|
||||
const res = createMockRes();
|
||||
res.revalidate = vi.fn().mockRejectedValueOnce(new Error("nope"));
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toBe("Error revalidating");
|
||||
});
|
||||
});
|
||||
106
src/__tests__/pages/api/search/searchSuggestion.test.js
Normal file
106
src/__tests__/pages/api/search/searchSuggestion.test.js
Normal file
@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { providers, getSettings, widgetsFromConfig, cachedRequest } = vi.hoisted(() => ({
|
||||
providers: {
|
||||
custom: { name: "Custom", url: false, suggestionUrl: null },
|
||||
google: { name: "Google", url: "https://google?q=", suggestionUrl: "https://google/suggest?q=" },
|
||||
empty: { name: "NoSuggest", url: "x", suggestionUrl: null },
|
||||
},
|
||||
getSettings: vi.fn(),
|
||||
widgetsFromConfig: vi.fn(),
|
||||
cachedRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("components/widgets/search/search", () => ({
|
||||
searchProviders: {
|
||||
custom: providers.custom,
|
||||
google: providers.google,
|
||||
empty: providers.empty,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/widget-helpers", () => ({
|
||||
widgetsFromConfig,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
cachedRequest,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/search/searchSuggestion";
|
||||
|
||||
describe("pages/api/search/searchSuggestion", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset provider objects since handler mutates the Custom provider.
|
||||
providers.custom.url = false;
|
||||
providers.custom.suggestionUrl = null;
|
||||
});
|
||||
|
||||
it("returns empty suggestions when providerName is unknown", async () => {
|
||||
const req = { query: { query: "hello", providerName: "Unknown" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body).toEqual(["hello", []]);
|
||||
});
|
||||
|
||||
it("returns empty suggestions when provider has no suggestionUrl", async () => {
|
||||
const req = { query: { query: "hello", providerName: "NoSuggest" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body).toEqual(["hello", []]);
|
||||
});
|
||||
|
||||
it("calls cachedRequest for a standard provider", async () => {
|
||||
cachedRequest.mockResolvedValueOnce(["q", ["a"]]);
|
||||
|
||||
const req = { query: { query: "hello world", providerName: "Google" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(cachedRequest).toHaveBeenCalledWith("https://google/suggest?q=hello%20world", 5, "Mozilla/5.0");
|
||||
expect(res.body).toEqual(["q", ["a"]]);
|
||||
});
|
||||
|
||||
it("resolves Custom provider suggestionUrl from widgets.yaml when present", async () => {
|
||||
widgetsFromConfig.mockResolvedValueOnce([
|
||||
{ type: "search", options: { url: "https://custom?q=", suggestionUrl: "https://custom/suggest?q=" } },
|
||||
]);
|
||||
cachedRequest.mockResolvedValueOnce(["q", ["x"]]);
|
||||
|
||||
const req = { query: { query: "hello", providerName: "Custom" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(cachedRequest).toHaveBeenCalledWith("https://custom/suggest?q=hello", 5, "Mozilla/5.0");
|
||||
expect(res.body).toEqual(["q", ["x"]]);
|
||||
});
|
||||
|
||||
it("falls back to quicklaunch custom settings when no search widget is configured", async () => {
|
||||
widgetsFromConfig.mockResolvedValueOnce([]);
|
||||
getSettings.mockReturnValueOnce({
|
||||
quicklaunch: { provider: "custom", url: "https://ql?q=", suggestionUrl: "https://ql/suggest?q=" },
|
||||
});
|
||||
cachedRequest.mockResolvedValueOnce(["q", ["y"]]);
|
||||
|
||||
const req = { query: { query: "hello", providerName: "Custom" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(cachedRequest).toHaveBeenCalledWith("https://ql/suggest?q=hello", 5, "Mozilla/5.0");
|
||||
});
|
||||
});
|
||||
30
src/__tests__/pages/api/services/index.test.js
Normal file
30
src/__tests__/pages/api/services/index.test.js
Normal file
@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { servicesResponse } = vi.hoisted(() => ({
|
||||
servicesResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/api-response", () => ({
|
||||
servicesResponse,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/services/index";
|
||||
|
||||
describe("pages/api/services/index", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns servicesResponse()", async () => {
|
||||
servicesResponse.mockResolvedValueOnce({ services: [] });
|
||||
|
||||
const req = {};
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body).toEqual({ services: [] });
|
||||
});
|
||||
});
|
||||
347
src/__tests__/pages/api/services/proxy.test.js
Normal file
347
src/__tests__/pages/api/services/proxy.test.js
Normal file
@ -0,0 +1,347 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { state, getServiceWidget, calendarProxy } = vi.hoisted(() => ({
|
||||
state: {
|
||||
genericResult: { ok: true },
|
||||
},
|
||||
getServiceWidget: vi.fn(),
|
||||
calendarProxy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => ({ debug: vi.fn(), error: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({ default: getServiceWidget }));
|
||||
|
||||
const handlerFn = vi.hoisted(() => ({ handler: vi.fn() }));
|
||||
vi.mock("utils/proxy/handlers/generic", () => ({ default: handlerFn.handler }));
|
||||
|
||||
// Calendar proxy is only used for an exception; keep it stubbed.
|
||||
vi.mock("widgets/calendar/proxy", () => ({ default: calendarProxy }));
|
||||
|
||||
// Provide a minimal widget registry for mapping tests.
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
linkwarden: {
|
||||
api: "{url}/api/v1/{endpoint}",
|
||||
mappings: {
|
||||
collections: { endpoint: "collections" },
|
||||
},
|
||||
},
|
||||
segments: {
|
||||
api: "{url}/{endpoint}",
|
||||
mappings: {
|
||||
item: { endpoint: "items/{id}", segments: ["id"] },
|
||||
},
|
||||
},
|
||||
queryparams: {
|
||||
api: "{url}/{endpoint}",
|
||||
mappings: {
|
||||
list: { endpoint: "list", params: ["limit"], optionalParams: ["q"] },
|
||||
},
|
||||
},
|
||||
endpointproxy: {
|
||||
api: "{url}/{endpoint}",
|
||||
mappings: {
|
||||
list: { endpoint: "list", proxyHandler: handlerFn.handler, headers: { "X-Test": "1" } },
|
||||
},
|
||||
},
|
||||
regex: {
|
||||
api: "{url}/{endpoint}",
|
||||
allowedEndpoints: /^ok\//,
|
||||
},
|
||||
ical: {
|
||||
api: "{url}/{endpoint}",
|
||||
proxyHandler: calendarProxy,
|
||||
},
|
||||
unifi_console: {
|
||||
api: "{url}/{endpoint}",
|
||||
proxyHandler: handlerFn.handler,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import servicesProxy from "pages/api/services/proxy";
|
||||
|
||||
function createMockRes() {
|
||||
const res = {
|
||||
statusCode: undefined,
|
||||
body: undefined,
|
||||
status: (code) => {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json: (data) => {
|
||||
res.body = data;
|
||||
return res;
|
||||
},
|
||||
send: (data) => {
|
||||
res.body = data;
|
||||
return res;
|
||||
},
|
||||
end: () => res,
|
||||
setHeader: vi.fn(),
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("pages/api/services/proxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("maps opaque endpoints using widget.mappings and calls the handler", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "linkwarden" });
|
||||
handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint }));
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await servicesProxy(req, res);
|
||||
|
||||
expect(handlerFn.handler).toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ endpoint: "collections" });
|
||||
});
|
||||
|
||||
it("returns 403 for unsupported endpoint mapping", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "linkwarden" });
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "nope" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await servicesProxy(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Unsupported service endpoint" });
|
||||
});
|
||||
|
||||
it("returns 403 for unknown widget types", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "does_not_exist" });
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await servicesProxy(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Unknown proxy service type" });
|
||||
});
|
||||
|
||||
it("quick-returns the proxy handler when no endpoint is provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "linkwarden" });
|
||||
handlerFn.handler.mockImplementation(async (_req, res) => res.status(200).json(state.genericResult));
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await servicesProxy(req, res);
|
||||
|
||||
expect(handlerFn.handler).toHaveBeenCalledTimes(1);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("applies the calendar exception and always delegates to calendarProxyHandler", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "calendar" });
|
||||
calendarProxy.mockImplementation(async (_req, res) => res.status(200).json({ ok: "calendar" }));
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "events" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await servicesProxy(req, res);
|
||||
|
||||
expect(calendarProxy).toHaveBeenCalledTimes(1);
|
||||
expect(handlerFn.handler).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ ok: "calendar" });
|
||||
});
|
||||
|
||||
it("applies the unifi_console exception when service and group are unifi_console", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "something_else" });
|
||||
handlerFn.handler.mockImplementation(async (_req, res) => res.status(200).json({ ok: "unifi" }));
|
||||
|
||||
const req = {
|
||||
method: "GET",
|
||||
query: { group: "unifi_console", service: "unifi_console", index: "0" },
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await servicesProxy(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ ok: "unifi" });
|
||||
});
|
||||
|
||||
it("rejects unsupported mapping methods", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "linkwarden" });
|
||||
|
||||
// Inject a mapping with a method requirement through the mocked registry.
|
||||
const widgets = (await import("widgets/widgets")).default;
|
||||
const originalMethod = widgets.linkwarden.mappings.collections.method;
|
||||
widgets.linkwarden.mappings.collections.method = "POST";
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await servicesProxy(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Unsupported method" });
|
||||
|
||||
widgets.linkwarden.mappings.collections.method = originalMethod;
|
||||
});
|
||||
|
||||
it("replaces endpoint segments and rejects unsupported segment keys/values", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "segments" });
|
||||
handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint }));
|
||||
|
||||
const res1 = createMockRes();
|
||||
await servicesProxy(
|
||||
{
|
||||
method: "GET",
|
||||
query: { group: "g", service: "s", index: "0", endpoint: "item", segments: JSON.stringify({ id: "123" }) },
|
||||
},
|
||||
res1,
|
||||
);
|
||||
expect(res1.statusCode).toBe(200);
|
||||
expect(res1.body).toEqual({ endpoint: "items/123" });
|
||||
|
||||
const res2 = createMockRes();
|
||||
await servicesProxy(
|
||||
{
|
||||
method: "GET",
|
||||
query: { group: "g", service: "s", index: "0", endpoint: "item", segments: JSON.stringify({ nope: "123" }) },
|
||||
},
|
||||
res2,
|
||||
);
|
||||
expect(res2.statusCode).toBe(403);
|
||||
expect(res2.body).toEqual({ error: "Unsupported segment" });
|
||||
|
||||
const res3 = createMockRes();
|
||||
await servicesProxy(
|
||||
{
|
||||
method: "GET",
|
||||
query: { group: "g", service: "s", index: "0", endpoint: "item", segments: JSON.stringify({ id: "../123" }) },
|
||||
},
|
||||
res3,
|
||||
);
|
||||
expect(res3.statusCode).toBe(403);
|
||||
expect(res3.body).toEqual({ error: "Unsupported segment" });
|
||||
});
|
||||
|
||||
it("adds query params based on mapping params + optionalParams", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "queryparams" });
|
||||
handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint }));
|
||||
|
||||
const req = {
|
||||
method: "GET",
|
||||
query: {
|
||||
group: "g",
|
||||
service: "s",
|
||||
index: "0",
|
||||
endpoint: "list",
|
||||
query: JSON.stringify({ limit: 10, q: "test" }),
|
||||
},
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await servicesProxy(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.endpoint).toBe("list?limit=10&q=test");
|
||||
});
|
||||
|
||||
it("passes mapping headers via req.extraHeaders and uses mapping.proxyHandler when provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "endpointproxy" });
|
||||
handlerFn.handler.mockImplementation(async (req, res) =>
|
||||
res.status(200).json({ headers: req.extraHeaders ?? null }),
|
||||
);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "list" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await servicesProxy(req, res);
|
||||
|
||||
expect(handlerFn.handler).toHaveBeenCalledTimes(1);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.headers).toEqual({ "X-Test": "1" });
|
||||
});
|
||||
|
||||
it("allows regex endpoints when widget.allowedEndpoints matches", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "regex" });
|
||||
handlerFn.handler.mockImplementation(async (_req, res) => res.status(200).json({ ok: true }));
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "ok/test" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await servicesProxy(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects unmapped proxy requests when no mapping and regex does not match", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "regex" });
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "nope" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await servicesProxy(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Unmapped proxy request." });
|
||||
});
|
||||
|
||||
it("falls back to the service proxy handler when mapping.proxyHandler is not a function", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "mapbroken" });
|
||||
handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint }));
|
||||
|
||||
const widgets = (await import("widgets/widgets")).default;
|
||||
widgets.mapbroken = {
|
||||
api: "{url}/{endpoint}",
|
||||
mappings: {
|
||||
x: { endpoint: "ok", proxyHandler: "nope" },
|
||||
},
|
||||
};
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "x" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await servicesProxy(req, res);
|
||||
|
||||
expect(handlerFn.handler).toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.endpoint).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns 403 when a widget defines a non-function proxyHandler", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "brokenhandler" });
|
||||
|
||||
const widgets = (await import("widgets/widgets")).default;
|
||||
widgets.brokenhandler = {
|
||||
api: "{url}/{endpoint}",
|
||||
proxyHandler: "nope",
|
||||
};
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "any" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await servicesProxy(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Unknown proxy service type" });
|
||||
});
|
||||
|
||||
it("returns 500 on unexpected errors", async () => {
|
||||
getServiceWidget.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await servicesProxy(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "Unexpected error" });
|
||||
});
|
||||
});
|
||||
103
src/__tests__/pages/api/siteMonitor.test.js
Normal file
103
src/__tests__/pages/api/siteMonitor.test.js
Normal file
@ -0,0 +1,103 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { getServiceItem, httpProxy, perf, logger } = vi.hoisted(() => ({
|
||||
getServiceItem: vi.fn(),
|
||||
httpProxy: vi.fn(),
|
||||
perf: { now: vi.fn() },
|
||||
logger: { debug: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("perf_hooks", () => ({
|
||||
performance: perf,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
getServiceItem,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/siteMonitor";
|
||||
|
||||
describe("pages/api/siteMonitor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 400 when the service item is missing", async () => {
|
||||
getServiceItem.mockResolvedValueOnce(null);
|
||||
|
||||
const req = { query: { groupName: "g", serviceName: "s" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toContain("Unable to find service");
|
||||
});
|
||||
|
||||
it("returns 400 when the monitor URL is missing", async () => {
|
||||
getServiceItem.mockResolvedValueOnce({ siteMonitor: "" });
|
||||
|
||||
const req = { query: { groupName: "g", serviceName: "s" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe("No http monitor URL given");
|
||||
});
|
||||
|
||||
it("uses HEAD and returns status + latency when the response is OK", async () => {
|
||||
getServiceItem.mockResolvedValueOnce({ siteMonitor: "http://example.com" });
|
||||
perf.now.mockReturnValueOnce(1).mockReturnValueOnce(11);
|
||||
httpProxy.mockResolvedValueOnce([200]);
|
||||
|
||||
const req = { query: { groupName: "g", serviceName: "s" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledWith("http://example.com", { method: "HEAD" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.status).toBe(200);
|
||||
expect(res.body.latency).toBe(10);
|
||||
});
|
||||
|
||||
it("falls back to GET when HEAD is rejected", async () => {
|
||||
getServiceItem.mockResolvedValueOnce({ siteMonitor: "http://example.com" });
|
||||
perf.now.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValueOnce(5).mockReturnValueOnce(15);
|
||||
httpProxy.mockResolvedValueOnce([500]).mockResolvedValueOnce([200]);
|
||||
|
||||
const req = { query: { groupName: "g", serviceName: "s" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenNthCalledWith(1, "http://example.com", { method: "HEAD" });
|
||||
expect(httpProxy).toHaveBeenNthCalledWith(2, "http://example.com");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ status: 200, latency: 10 });
|
||||
});
|
||||
|
||||
it("returns 400 when httpProxy throws", async () => {
|
||||
getServiceItem.mockResolvedValueOnce({ siteMonitor: "http://example.com" });
|
||||
httpProxy.mockRejectedValueOnce(new Error("nope"));
|
||||
|
||||
const req = { query: { groupName: "g", serviceName: "s" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toContain("Error attempting http monitor");
|
||||
});
|
||||
});
|
||||
41
src/__tests__/pages/api/theme.test.js
Normal file
41
src/__tests__/pages/api/theme.test.js
Normal file
@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { checkAndCopyConfig, getSettings } = vi.hoisted(() => ({
|
||||
checkAndCopyConfig: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
default: checkAndCopyConfig,
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/theme";
|
||||
|
||||
describe("pages/api/theme", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns defaults when settings are missing", () => {
|
||||
getSettings.mockReturnValueOnce({});
|
||||
|
||||
const res = createMockRes();
|
||||
handler({ res });
|
||||
|
||||
expect(checkAndCopyConfig).toHaveBeenCalledWith("settings.yaml");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ color: "slate", theme: "dark" });
|
||||
});
|
||||
|
||||
it("returns configured color + theme when present", () => {
|
||||
getSettings.mockReturnValueOnce({ color: "red", theme: "light" });
|
||||
|
||||
const res = createMockRes();
|
||||
handler({ res });
|
||||
|
||||
expect(res.body).toEqual({ color: "red", theme: "light" });
|
||||
});
|
||||
});
|
||||
30
src/__tests__/pages/api/validate.test.js
Normal file
30
src/__tests__/pages/api/validate.test.js
Normal file
@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { checkAndCopyConfig } = vi.hoisted(() => ({
|
||||
checkAndCopyConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
default: checkAndCopyConfig,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/validate";
|
||||
|
||||
describe("pages/api/validate", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns errors for any configs that don't validate", async () => {
|
||||
checkAndCopyConfig.mockReturnValueOnce(true).mockReturnValueOnce("settings bad").mockReturnValue(true);
|
||||
|
||||
const req = {};
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body).toEqual(["settings bad"]);
|
||||
});
|
||||
});
|
||||
123
src/__tests__/pages/api/widgets/glances.test.js
Normal file
123
src/__tests__/pages/api/widgets/glances.test.js
Normal file
@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { getPrivateWidgetOptions, httpProxy, logger } = vi.hoisted(() => ({
|
||||
getPrivateWidgetOptions: vi.fn(),
|
||||
httpProxy: vi.fn(),
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/widget-helpers", () => ({
|
||||
getPrivateWidgetOptions,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/widgets/glances";
|
||||
|
||||
describe("pages/api/widgets/glances", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 400 when the widget URL is missing", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
|
||||
const req = { query: { index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe("Missing Glances URL");
|
||||
});
|
||||
|
||||
it("returns cpu/load/mem and includes optional endpoints when requested", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances", username: "u", password: "p" });
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))]) // cpu
|
||||
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ avg: 2 }))]) // load
|
||||
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ available: 3 }))]) // mem
|
||||
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify("1 days"))]) // uptime
|
||||
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify([{ label: "cpu_thermal", value: 50 }]))]) // sensors
|
||||
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify([{ mnt_point: "/", percent: 1 }]))]); // fs
|
||||
|
||||
const req = { query: { index: "0", uptime: "1", cputemp: "1", disk: "1", version: "4" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledWith(
|
||||
"http://glances/api/4/cpu",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ Authorization: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
cpu: { total: 1 },
|
||||
load: { avg: 2 },
|
||||
mem: { available: 3 },
|
||||
uptime: "1 days",
|
||||
sensors: [{ label: "cpu_thermal", value: 50 }],
|
||||
fs: [{ mnt_point: "/", percent: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call optional endpoints unless requested", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))]) // cpu
|
||||
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ avg: 2 }))]) // load
|
||||
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ available: 3 }))]); // mem
|
||||
|
||||
const req = { query: { index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(3);
|
||||
expect(httpProxy.mock.calls[0][1].headers.Authorization).toBeUndefined();
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 400 when glances returns 401", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
|
||||
httpProxy.mockResolvedValueOnce([401, null, Buffer.from("nope")]);
|
||||
|
||||
const req = { query: { index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual(expect.objectContaining({ error: expect.stringContaining("Authorization failure") }));
|
||||
});
|
||||
|
||||
it("returns 400 when glances returns a non-200 status for a downstream call", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))]) // cpu
|
||||
.mockResolvedValueOnce([500, null, Buffer.from("nope")]); // load
|
||||
|
||||
const req = { query: { index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual(expect.objectContaining({ error: expect.stringContaining("HTTP 500") }));
|
||||
});
|
||||
});
|
||||
30
src/__tests__/pages/api/widgets/index.test.js
Normal file
30
src/__tests__/pages/api/widgets/index.test.js
Normal file
@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { widgetsResponse } = vi.hoisted(() => ({
|
||||
widgetsResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/api-response", () => ({
|
||||
widgetsResponse,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/widgets/index";
|
||||
|
||||
describe("pages/api/widgets/index", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns widgetsResponse()", async () => {
|
||||
widgetsResponse.mockResolvedValueOnce([{ type: "logo", options: {} }]);
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body).toEqual([{ type: "logo", options: {} }]);
|
||||
});
|
||||
});
|
||||
204
src/__tests__/pages/api/widgets/kubernetes.test.js
Normal file
204
src/__tests__/pages/api/widgets/kubernetes.test.js
Normal file
@ -0,0 +1,204 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { kc, coreApi, metricsApi, getKubeConfig, parseCpu, parseMemory, logger } = vi.hoisted(() => {
|
||||
const coreApi = { listNode: vi.fn() };
|
||||
const metricsApi = { getNodeMetrics: vi.fn() };
|
||||
|
||||
const kc = {
|
||||
makeApiClient: vi.fn(() => coreApi),
|
||||
};
|
||||
|
||||
return {
|
||||
kc,
|
||||
coreApi,
|
||||
metricsApi,
|
||||
getKubeConfig: vi.fn(),
|
||||
parseCpu: vi.fn(),
|
||||
parseMemory: vi.fn(),
|
||||
logger: { error: vi.fn(), debug: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@kubernetes/client-node", () => ({
|
||||
CoreV1Api: class CoreV1Api {},
|
||||
Metrics: class Metrics {
|
||||
constructor() {
|
||||
return metricsApi;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/kubernetes", () => ({
|
||||
getKubeConfig,
|
||||
}));
|
||||
|
||||
vi.mock("utils/kubernetes/utils", () => ({
|
||||
parseCpu,
|
||||
parseMemory,
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/widgets/kubernetes";
|
||||
|
||||
describe("pages/api/widgets/kubernetes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 500 when no kube config is available", async () => {
|
||||
getKubeConfig.mockReturnValueOnce(null);
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error).toBe("No kubernetes configuration");
|
||||
});
|
||||
|
||||
it("returns 500 when listing nodes fails", async () => {
|
||||
getKubeConfig.mockReturnValueOnce(kc);
|
||||
coreApi.listNode.mockResolvedValueOnce(null);
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error).toContain("fetching nodes");
|
||||
});
|
||||
|
||||
it("logs and returns 500 when listing nodes throws", async () => {
|
||||
getKubeConfig.mockReturnValueOnce(kc);
|
||||
coreApi.listNode.mockRejectedValueOnce({ statusCode: 500, body: "nope", response: "nope" });
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error).toContain("fetching nodes");
|
||||
});
|
||||
|
||||
it("returns 500 when metrics lookup fails", async () => {
|
||||
getKubeConfig.mockReturnValueOnce(kc);
|
||||
parseMemory.mockReturnValue(100);
|
||||
coreApi.listNode.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: "n1" },
|
||||
status: { capacity: { cpu: "1", memory: "100" }, conditions: [{ type: "Ready", status: "True" }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
metricsApi.getNodeMetrics.mockRejectedValueOnce(new Error("nope"));
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error).toContain("Error getting metrics");
|
||||
});
|
||||
|
||||
it("returns cluster totals and per-node usage", async () => {
|
||||
getKubeConfig.mockReturnValueOnce(kc);
|
||||
|
||||
parseMemory.mockImplementation((value) => {
|
||||
if (value === "100") return 100;
|
||||
if (value === "50") return 50;
|
||||
if (value === "30") return 30;
|
||||
return 0;
|
||||
});
|
||||
parseCpu.mockImplementation((value) => {
|
||||
if (value === "100m") return 0.1;
|
||||
if (value === "200m") return 0.2;
|
||||
return 0;
|
||||
});
|
||||
|
||||
coreApi.listNode.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: "n1" },
|
||||
status: { capacity: { cpu: "1", memory: "100" }, conditions: [{ type: "Ready", status: "True" }] },
|
||||
},
|
||||
{
|
||||
metadata: { name: "n2" },
|
||||
status: { capacity: { cpu: "2", memory: "50" }, conditions: [{ type: "Ready", status: "False" }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
metricsApi.getNodeMetrics.mockResolvedValueOnce({
|
||||
items: [
|
||||
{ metadata: { name: "n1" }, usage: { cpu: "100m", memory: "30" } },
|
||||
{ metadata: { name: "n2" }, usage: { cpu: "200m", memory: "50" } },
|
||||
],
|
||||
});
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.cluster.cpu.total).toBe(3);
|
||||
expect(res.body.cluster.cpu.load).toBeCloseTo(0.3);
|
||||
expect(res.body.cluster.memory.total).toBe(150);
|
||||
expect(res.body.nodes).toHaveLength(2);
|
||||
expect(res.body.nodes.find((n) => n.name === "n1").cpu.percent).toBeCloseTo(10);
|
||||
});
|
||||
|
||||
it("returns a metrics error when metrics contain an unexpected node name", async () => {
|
||||
getKubeConfig.mockReturnValueOnce(kc);
|
||||
parseMemory.mockReturnValue(100);
|
||||
parseCpu.mockReturnValue(0.1);
|
||||
|
||||
coreApi.listNode.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: "n1" },
|
||||
status: { capacity: { cpu: "1", memory: "100" }, conditions: [{ type: "Ready", status: "True" }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
metricsApi.getNodeMetrics.mockResolvedValueOnce({
|
||||
items: [{ metadata: { name: "n2" }, usage: { cpu: "100m", memory: "30" } }],
|
||||
});
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error).toContain("Error getting metrics");
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 500 when an unexpected error is thrown", async () => {
|
||||
getKubeConfig.mockImplementationOnce(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: "unknown error" });
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
126
src/__tests__/pages/api/widgets/longhorn.test.js
Normal file
126
src/__tests__/pages/api/widgets/longhorn.test.js
Normal file
@ -0,0 +1,126 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { getSettings, httpProxy, logger } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
httpProxy: vi.fn(),
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/widgets/longhorn";
|
||||
|
||||
describe("pages/api/widgets/longhorn", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 400 when the longhorn URL isn't configured", async () => {
|
||||
getSettings.mockReturnValueOnce({ providers: { longhorn: {} } });
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe("Missing Longhorn URL");
|
||||
});
|
||||
|
||||
it("parses and aggregates node disk totals, including a total node", async () => {
|
||||
getSettings.mockReturnValueOnce({
|
||||
providers: { longhorn: { url: "http://lh", username: "u", password: "p" } },
|
||||
});
|
||||
|
||||
const payload = {
|
||||
data: [
|
||||
{
|
||||
id: "n1",
|
||||
disks: {
|
||||
d1: { storageAvailable: 1, storageMaximum: 10, storageReserved: 2, storageScheduled: 3 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "n2",
|
||||
disks: {
|
||||
d1: { storageAvailable: 4, storageMaximum: 20, storageReserved: 5, storageScheduled: 6 },
|
||||
d2: { storageAvailable: 1, storageMaximum: 1, storageReserved: 1, storageScheduled: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", JSON.stringify(payload)]);
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledWith(
|
||||
"http://lh/v1/nodes",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ Authorization: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
expect(res.headers["Content-Type"]).toBe("application/json");
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const nodes = res.body.nodes;
|
||||
expect(nodes.map((n) => n.id)).toEqual(["n1", "n2", "total"]);
|
||||
expect(nodes.find((n) => n.id === "total")).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "total",
|
||||
available: 6,
|
||||
maximum: 31,
|
||||
reserved: 8,
|
||||
scheduled: 10,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles nodes without disks and logs non-200 responses", async () => {
|
||||
getSettings.mockReturnValueOnce({ providers: { longhorn: { url: "http://lh" } } });
|
||||
|
||||
const payload = { data: [{ id: "n1" }] };
|
||||
httpProxy.mockResolvedValueOnce([401, "application/json", JSON.stringify(payload)]);
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.nodes).toEqual([
|
||||
{ id: "n1", available: 0, maximum: 0, reserved: 0, scheduled: 0 },
|
||||
{ id: "total", available: 0, maximum: 0, reserved: 0, scheduled: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nodes=null when the API returns a null payload", async () => {
|
||||
getSettings.mockReturnValueOnce({ providers: { longhorn: { url: "http://lh" } } });
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", "null"]);
|
||||
|
||||
const req = { query: {} };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ nodes: null });
|
||||
});
|
||||
});
|
||||
52
src/__tests__/pages/api/widgets/openmeteo.test.js
Normal file
52
src/__tests__/pages/api/widgets/openmeteo.test.js
Normal file
@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { cachedRequest } = vi.hoisted(() => ({
|
||||
cachedRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
cachedRequest,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/widgets/openmeteo";
|
||||
|
||||
describe("pages/api/widgets/openmeteo", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("builds the open-meteo URL with units + timezone and calls cachedRequest", async () => {
|
||||
cachedRequest.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const req = {
|
||||
query: { latitude: "1", longitude: "2", units: "metric", cache: "5" },
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(cachedRequest).toHaveBeenCalledWith(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude=1&longitude=2&daily=sunrise,sunset¤t_weather=true&temperature_unit=celsius&timezone=auto",
|
||||
"5",
|
||||
);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("uses the provided timezone and fahrenheit for non-metric units", async () => {
|
||||
cachedRequest.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const req = {
|
||||
query: { latitude: "1", longitude: "2", units: "imperial", cache: 1, timezone: "UTC" },
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(cachedRequest).toHaveBeenCalledWith(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude=1&longitude=2&daily=sunrise,sunset¤t_weather=true&temperature_unit=fahrenheit&timezone=UTC",
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
122
src/__tests__/pages/api/widgets/openweathermap.test.js
Normal file
122
src/__tests__/pages/api/widgets/openweathermap.test.js
Normal file
@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { getSettings, getPrivateWidgetOptions, cachedRequest } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
getPrivateWidgetOptions: vi.fn(),
|
||||
cachedRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/widget-helpers", () => ({
|
||||
getPrivateWidgetOptions,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
cachedRequest,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/widgets/openweathermap";
|
||||
|
||||
describe("pages/api/widgets/openweathermap", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 400 when no API key and no provider are supplied", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
|
||||
const req = { query: { latitude: "1", longitude: "2", units: "metric", lang: "en", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Missing API key or provider" });
|
||||
});
|
||||
|
||||
it("returns 400 when provider doesn't match endpoint and no per-widget key exists", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
|
||||
const req = { query: { latitude: "1", longitude: "2", units: "metric", lang: "en", provider: "weatherapi" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid provider for endpoint" });
|
||||
});
|
||||
|
||||
it("uses key from widget options when present", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({ apiKey: "from-widget" });
|
||||
cachedRequest.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const req = {
|
||||
query: { latitude: "1", longitude: "2", units: "metric", lang: "en", cache: "1", index: "2" },
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(getPrivateWidgetOptions).toHaveBeenCalledWith("openweathermap", "2");
|
||||
expect(cachedRequest).toHaveBeenCalledWith(
|
||||
"https://api.openweathermap.org/data/2.5/weather?lat=1&lon=2&appid=from-widget&units=metric&lang=en",
|
||||
"1",
|
||||
);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("falls back to settings provider key when provider=openweathermap", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
getSettings.mockReturnValueOnce({ providers: { openweathermap: "from-settings" } });
|
||||
cachedRequest.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const req = {
|
||||
query: {
|
||||
latitude: "1",
|
||||
longitude: "2",
|
||||
units: "imperial",
|
||||
lang: "en",
|
||||
provider: "openweathermap",
|
||||
cache: 2,
|
||||
index: "0",
|
||||
},
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(cachedRequest).toHaveBeenCalledWith(
|
||||
"https://api.openweathermap.org/data/2.5/weather?lat=1&lon=2&appid=from-settings&units=imperial&lang=en",
|
||||
2,
|
||||
);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("returns 400 when provider=openweathermap but settings do not provide an api key", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
getSettings.mockReturnValueOnce({ providers: {} });
|
||||
|
||||
const req = {
|
||||
query: {
|
||||
latitude: "1",
|
||||
longitude: "2",
|
||||
units: "metric",
|
||||
lang: "en",
|
||||
provider: "openweathermap",
|
||||
cache: 1,
|
||||
index: "0",
|
||||
},
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Missing API key" });
|
||||
});
|
||||
});
|
||||
140
src/__tests__/pages/api/widgets/resources.test.js
Normal file
140
src/__tests__/pages/api/widgets/resources.test.js
Normal file
@ -0,0 +1,140 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { si, logger } = vi.hoisted(() => ({
|
||||
si: {
|
||||
currentLoad: vi.fn(),
|
||||
fsSize: vi.fn(),
|
||||
mem: vi.fn(),
|
||||
cpuTemperature: vi.fn(),
|
||||
time: vi.fn(),
|
||||
networkStats: vi.fn(),
|
||||
networkInterfaceDefault: vi.fn(),
|
||||
},
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("systeminformation", () => ({ default: si }));
|
||||
|
||||
import handler from "pages/api/widgets/resources";
|
||||
|
||||
describe("pages/api/widgets/resources", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns CPU load data", async () => {
|
||||
si.currentLoad.mockResolvedValueOnce({ currentLoad: 12.34, avgLoad: 1.23 });
|
||||
|
||||
const req = { query: { type: "cpu" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.cpu).toEqual({ usage: 12.34, load: 1.23 });
|
||||
});
|
||||
|
||||
it("returns 404 when requested disk target does not exist", async () => {
|
||||
si.fsSize.mockResolvedValueOnce([{ mount: "/" }]);
|
||||
|
||||
const req = { query: { type: "disk", target: "/missing" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: "Resource not available." });
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns disk info for the requested mount", async () => {
|
||||
si.fsSize.mockResolvedValueOnce([{ mount: "/data", size: 1 }]);
|
||||
|
||||
const req = { query: { type: "disk", target: "/data" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.drive).toEqual({ mount: "/data", size: 1 });
|
||||
});
|
||||
|
||||
it("returns memory, cpu temp and uptime", async () => {
|
||||
si.mem.mockResolvedValueOnce({ total: 10 });
|
||||
si.cpuTemperature.mockResolvedValueOnce({ main: 50 });
|
||||
si.time.mockResolvedValueOnce({ uptime: 123 });
|
||||
|
||||
const resMem = createMockRes();
|
||||
await handler({ query: { type: "memory" } }, resMem);
|
||||
expect(resMem.statusCode).toBe(200);
|
||||
expect(resMem.body.memory).toEqual({ total: 10 });
|
||||
|
||||
const resTemp = createMockRes();
|
||||
await handler({ query: { type: "cputemp" } }, resTemp);
|
||||
expect(resTemp.statusCode).toBe(200);
|
||||
expect(resTemp.body.cputemp).toEqual({ main: 50 });
|
||||
|
||||
const resUptime = createMockRes();
|
||||
await handler({ query: { type: "uptime" } }, resUptime);
|
||||
expect(resUptime.statusCode).toBe(200);
|
||||
expect(resUptime.body.uptime).toBe(123);
|
||||
});
|
||||
|
||||
it("returns 404 when requested network interface does not exist", async () => {
|
||||
si.networkStats.mockResolvedValueOnce([{ iface: "en0" }]);
|
||||
|
||||
const req = { query: { type: "network", interfaceName: "missing" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: "Interface not found" });
|
||||
});
|
||||
|
||||
it("returns default interface network stats", async () => {
|
||||
si.networkStats.mockResolvedValueOnce([{ iface: "en0", rx_bytes: 1 }]);
|
||||
si.networkInterfaceDefault.mockResolvedValueOnce("en0");
|
||||
|
||||
const req = { query: { type: "network" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.interface).toBe("en0");
|
||||
expect(res.body.network).toEqual({ iface: "en0", rx_bytes: 1 });
|
||||
});
|
||||
|
||||
it("returns 404 when the default interface cannot be found in networkStats", async () => {
|
||||
si.networkStats.mockResolvedValueOnce([{ iface: "en0", rx_bytes: 1 }]);
|
||||
si.networkInterfaceDefault.mockResolvedValueOnce("en1");
|
||||
|
||||
const req = { query: { type: "network" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body).toEqual({ error: "Default interface not found" });
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid type", async () => {
|
||||
const req = { query: { type: "nope" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "invalid type" });
|
||||
});
|
||||
});
|
||||
117
src/__tests__/pages/api/widgets/stocks.test.js
Normal file
117
src/__tests__/pages/api/widgets/stocks.test.js
Normal file
@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { getSettings, cachedRequest, logger } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
cachedRequest: vi.fn(),
|
||||
logger: { debug: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
cachedRequest,
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/widgets/stocks";
|
||||
|
||||
describe("pages/api/widgets/stocks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("validates watchlist and provider", async () => {
|
||||
const res1 = createMockRes();
|
||||
await handler({ query: {} }, res1);
|
||||
expect(res1.statusCode).toBe(400);
|
||||
|
||||
const res2 = createMockRes();
|
||||
await handler({ query: { watchlist: "null", provider: "finnhub" } }, res2);
|
||||
expect(res2.statusCode).toBe(400);
|
||||
|
||||
const res3 = createMockRes();
|
||||
await handler({ query: { watchlist: "AAPL,AAPL", provider: "finnhub" } }, res3);
|
||||
expect(res3.statusCode).toBe(400);
|
||||
expect(res3.body.error).toContain("duplicates");
|
||||
|
||||
const res4 = createMockRes();
|
||||
await handler({ query: { watchlist: "AAPL", provider: "nope" } }, res4);
|
||||
expect(res4.statusCode).toBe(400);
|
||||
expect(res4.body.error).toContain("Invalid provider");
|
||||
|
||||
const res5 = createMockRes();
|
||||
await handler({ query: { watchlist: "AAPL" } }, res5);
|
||||
expect(res5.statusCode).toBe(400);
|
||||
expect(res5.body.error).toContain("Missing provider");
|
||||
|
||||
const res6 = createMockRes();
|
||||
await handler({ query: { watchlist: "A,B,C,D,E,F,G,H,I", provider: "finnhub" } }, res6);
|
||||
expect(res6.statusCode).toBe(400);
|
||||
expect(res6.body.error).toContain("Max items");
|
||||
});
|
||||
|
||||
it("returns 400 when API key isn't configured for provider", async () => {
|
||||
getSettings.mockReturnValueOnce({ providers: {} });
|
||||
|
||||
const req = { query: { watchlist: "AAPL", provider: "finnhub" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toContain("API Key");
|
||||
});
|
||||
|
||||
it("tolerates missing providers config and returns a helpful error", async () => {
|
||||
getSettings.mockReturnValueOnce({});
|
||||
|
||||
const req = { query: { watchlist: "AAPL", provider: "finnhub" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toContain("API Key");
|
||||
});
|
||||
|
||||
it("returns a normalized stocks response and rounds values", async () => {
|
||||
getSettings.mockReturnValueOnce({ providers: { finnhub: "k" } });
|
||||
|
||||
cachedRequest
|
||||
.mockResolvedValueOnce({ c: 10.123, dp: -1.234 }) // AAPL
|
||||
.mockResolvedValueOnce({ c: null, dp: null }); // MSFT
|
||||
|
||||
const req = { query: { watchlist: "AAPL,MSFT", provider: "finnhub", cache: "1" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(cachedRequest).toHaveBeenCalledWith("https://finnhub.io/api/v1/quote?symbol=AAPL&token=k", "1");
|
||||
expect(res.body).toEqual({
|
||||
stocks: [
|
||||
{ ticker: "AAPL", currentPrice: "10.12", percentChange: -1.23 },
|
||||
{ ticker: "MSFT", currentPrice: null, percentChange: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null entries when the watchlist includes empty tickers", async () => {
|
||||
getSettings.mockReturnValueOnce({ providers: { finnhub: "k" } });
|
||||
cachedRequest.mockResolvedValueOnce({ c: 1, dp: 1 });
|
||||
|
||||
const req = { query: { watchlist: "AAPL,", provider: "finnhub" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.body.stocks[0]).toEqual({ ticker: "AAPL", currentPrice: "1.00", percentChange: 1 });
|
||||
expect(res.body.stocks[1]).toEqual({ ticker: null, currentPrice: null, percentChange: null });
|
||||
});
|
||||
});
|
||||
98
src/__tests__/pages/api/widgets/weather.test.js
Normal file
98
src/__tests__/pages/api/widgets/weather.test.js
Normal file
@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { getSettings, getPrivateWidgetOptions, cachedRequest } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
getPrivateWidgetOptions: vi.fn(),
|
||||
cachedRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/widget-helpers", () => ({
|
||||
getPrivateWidgetOptions,
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
cachedRequest,
|
||||
}));
|
||||
|
||||
import handler from "pages/api/widgets/weather";
|
||||
|
||||
describe("pages/api/widgets/weatherapi", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 400 when no API key and no provider are supplied", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
|
||||
const req = { query: { latitude: "1", longitude: "2", lang: "en", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Missing API key or provider" });
|
||||
});
|
||||
|
||||
it("uses key from widget options when present", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({ apiKey: "from-widget" });
|
||||
cachedRequest.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const req = { query: { latitude: "1", longitude: "2", lang: "en", cache: 1, index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(cachedRequest).toHaveBeenCalledWith(
|
||||
"http://api.weatherapi.com/v1/current.json?q=1,2&key=from-widget&lang=en",
|
||||
1,
|
||||
);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("falls back to settings provider key when provider=weatherapi", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
getSettings.mockReturnValueOnce({ providers: { weatherapi: "from-settings" } });
|
||||
cachedRequest.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const req = { query: { latitude: "1", longitude: "2", lang: "en", provider: "weatherapi", cache: "2" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(cachedRequest).toHaveBeenCalledWith(
|
||||
"http://api.weatherapi.com/v1/current.json?q=1,2&key=from-settings&lang=en",
|
||||
"2",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsupported providers", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
|
||||
const req = { query: { latitude: "1", longitude: "2", lang: "en", provider: "nope" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid provider for endpoint" });
|
||||
});
|
||||
|
||||
it("returns 400 when a provider is set but no API key can be resolved", async () => {
|
||||
getPrivateWidgetOptions.mockResolvedValueOnce({});
|
||||
getSettings.mockReturnValueOnce({ providers: {} });
|
||||
|
||||
const req = { query: { latitude: "1", longitude: "2", lang: "en", provider: "weatherapi" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Missing API key" });
|
||||
});
|
||||
});
|
||||
42
src/__tests__/pages/browserconfig.xml.test.js
Normal file
42
src/__tests__/pages/browserconfig.xml.test.js
Normal file
@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import themes from "utils/styles/themes";
|
||||
|
||||
const { getSettings } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
import BrowserConfig, { getServerSideProps } from "pages/browserconfig.xml.jsx";
|
||||
|
||||
function createMockRes() {
|
||||
return {
|
||||
setHeader: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("pages/browserconfig.xml", () => {
|
||||
it("writes a browserconfig xml response using the selected theme color", async () => {
|
||||
getSettings.mockReturnValueOnce({ color: "slate", theme: "dark" });
|
||||
const res = createMockRes();
|
||||
|
||||
await getServerSideProps({ res });
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "text/xml");
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
|
||||
const xml = res.write.mock.calls[0][0];
|
||||
expect(xml).toContain('<?xml version="1.0" encoding="utf-8"?>');
|
||||
expect(xml).toContain('<square150x150logo src="/mstile-150x150.png?v=2"/>');
|
||||
expect(xml).toContain(`<TileColor>${themes.slate.dark}</TileColor>`);
|
||||
});
|
||||
|
||||
it("exports a placeholder component", () => {
|
||||
expect(BrowserConfig()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
533
src/__tests__/pages/index.test.jsx
Normal file
533
src/__tests__/pages/index.test.jsx
Normal file
@ -0,0 +1,533 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ColorContext } from "utils/contexts/color";
|
||||
import { SettingsContext } from "utils/contexts/settings";
|
||||
import { TabContext } from "utils/contexts/tab";
|
||||
import { ThemeContext } from "utils/contexts/theme";
|
||||
|
||||
const {
|
||||
state,
|
||||
router,
|
||||
i18n,
|
||||
getSettings,
|
||||
servicesResponse,
|
||||
bookmarksResponse,
|
||||
widgetsResponse,
|
||||
serverSideTranslations,
|
||||
logger,
|
||||
useSWR,
|
||||
useWindowFocus,
|
||||
} = vi.hoisted(() => {
|
||||
const state = {
|
||||
throwIn: null,
|
||||
validateData: [],
|
||||
hashData: null,
|
||||
mutateHash: vi.fn(),
|
||||
servicesData: [],
|
||||
bookmarksData: [],
|
||||
widgetsData: [],
|
||||
quickLaunchProps: null,
|
||||
widgetCalls: [],
|
||||
windowFocused: false,
|
||||
};
|
||||
|
||||
const router = { asPath: "/" };
|
||||
const i18n = { language: "en", changeLanguage: vi.fn() };
|
||||
|
||||
const getSettings = vi.fn(() => ({
|
||||
providers: {},
|
||||
language: "en",
|
||||
title: "Homepage",
|
||||
}));
|
||||
|
||||
const servicesResponse = vi.fn(async () => {
|
||||
if (state.throwIn === "services") throw new Error("services failed");
|
||||
return [{ name: "svc" }];
|
||||
});
|
||||
const bookmarksResponse = vi.fn(async () => {
|
||||
if (state.throwIn === "bookmarks") throw new Error("bookmarks failed");
|
||||
return [{ name: "bm" }];
|
||||
});
|
||||
const widgetsResponse = vi.fn(async () => {
|
||||
if (state.throwIn === "widgets") throw new Error("widgets failed");
|
||||
return [{ type: "search" }];
|
||||
});
|
||||
|
||||
const serverSideTranslations = vi.fn(async (language) => ({ _translations: language }));
|
||||
const logger = { error: vi.fn() };
|
||||
|
||||
const useSWR = vi.fn((key) => {
|
||||
if (key === "/api/validate") return { data: state.validateData };
|
||||
if (key === "/api/hash") return { data: state.hashData, mutate: state.mutateHash };
|
||||
if (key === "/api/services") return { data: state.servicesData };
|
||||
if (key === "/api/bookmarks") return { data: state.bookmarksData };
|
||||
if (key === "/api/widgets") return { data: state.widgetsData };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
const useWindowFocus = vi.fn(() => state.windowFocused);
|
||||
|
||||
return {
|
||||
state,
|
||||
router,
|
||||
i18n,
|
||||
getSettings,
|
||||
servicesResponse,
|
||||
bookmarksResponse,
|
||||
widgetsResponse,
|
||||
serverSideTranslations,
|
||||
logger,
|
||||
useSWR,
|
||||
useWindowFocus,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () => () => null,
|
||||
}));
|
||||
vi.mock("next/head", () => ({ default: ({ children }) => children }));
|
||||
vi.mock("next/script", () => ({ default: () => null }));
|
||||
vi.mock("next/router", () => ({ useRouter: () => router }));
|
||||
|
||||
vi.mock("next-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
i18n,
|
||||
t: (k) => k,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next-i18next/serverSideTranslations", () => ({
|
||||
serverSideTranslations,
|
||||
}));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: useSWR,
|
||||
SWRConfig: ({ children }) => children,
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/api-response", () => ({
|
||||
servicesResponse,
|
||||
bookmarksResponse,
|
||||
widgetsResponse,
|
||||
}));
|
||||
|
||||
vi.mock("utils/hooks/window-focus", () => ({
|
||||
default: useWindowFocus,
|
||||
}));
|
||||
|
||||
vi.mock("components/bookmarks/group", () => ({
|
||||
default: ({ bookmarks }) => <div data-testid="bookmarks-group">{bookmarks?.name}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("components/services/group", () => ({
|
||||
default: ({ group }) => <div data-testid="services-group">{group?.name}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("components/errorboundry", () => ({
|
||||
default: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("components/tab", () => ({
|
||||
default: ({ tab }) => <li data-testid="tab">{tab}</li>,
|
||||
slugifyAndEncode: (tabName) =>
|
||||
tabName !== undefined ? encodeURIComponent(tabName.toString().replace(/\\s+/g, "-").toLowerCase()) : "",
|
||||
}));
|
||||
|
||||
vi.mock("components/quicklaunch", () => ({
|
||||
default: (props) => {
|
||||
state.quickLaunchProps = props;
|
||||
return (
|
||||
<div data-testid="quicklaunch">
|
||||
{props.isOpen ? "open" : "closed"}:{props.servicesAndBookmarks?.length ?? 0}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("components/widgets/widget", () => ({
|
||||
default: ({ widget, style }) => {
|
||||
state.widgetCalls.push({ widget, style });
|
||||
return <div data-testid="widget">{widget?.type}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("components/toggles/revalidate", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
describe("pages/index getStaticProps", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.throwIn = null;
|
||||
state.validateData = [];
|
||||
state.hashData = null;
|
||||
state.servicesData = [];
|
||||
state.bookmarksData = [];
|
||||
state.widgetsData = [];
|
||||
state.quickLaunchProps = null;
|
||||
state.widgetCalls = [];
|
||||
state.windowFocused = false;
|
||||
router.asPath = "/";
|
||||
i18n.changeLanguage.mockClear();
|
||||
});
|
||||
|
||||
it("returns initial settings and api fallbacks for swr", async () => {
|
||||
getSettings.mockReturnValueOnce({ providers: { x: 1 }, language: "en", title: "Homepage" });
|
||||
|
||||
const { getStaticProps } = await import("pages/index.jsx");
|
||||
const result = await getStaticProps();
|
||||
|
||||
expect(result.props.initialSettings).toEqual({ language: "en", title: "Homepage" });
|
||||
expect(result.props.fallback["/api/services"]).toEqual([{ name: "svc" }]);
|
||||
expect(result.props.fallback["/api/bookmarks"]).toEqual([{ name: "bm" }]);
|
||||
expect(result.props.fallback["/api/widgets"]).toEqual([{ type: "search" }]);
|
||||
expect(result.props.fallback["/api/hash"]).toBe(false);
|
||||
expect(serverSideTranslations).toHaveBeenCalledWith("en");
|
||||
});
|
||||
|
||||
it("normalizes legacy language codes before requesting translations", async () => {
|
||||
getSettings.mockReturnValueOnce({ providers: {}, language: "zh-CN" });
|
||||
|
||||
const { getStaticProps } = await import("pages/index.jsx");
|
||||
await getStaticProps();
|
||||
|
||||
expect(serverSideTranslations).toHaveBeenCalledWith("zh-Hans");
|
||||
});
|
||||
|
||||
it("falls back to empty settings and en translations on errors", async () => {
|
||||
getSettings.mockReturnValueOnce({ providers: {}, language: "de" });
|
||||
state.throwIn = "services";
|
||||
|
||||
const { getStaticProps } = await import("pages/index.jsx");
|
||||
const result = await getStaticProps();
|
||||
|
||||
expect(result.props.initialSettings).toEqual({});
|
||||
expect(result.props.fallback["/api/services"]).toEqual([]);
|
||||
expect(result.props.fallback["/api/bookmarks"]).toEqual([]);
|
||||
expect(result.props.fallback["/api/widgets"]).toEqual([]);
|
||||
expect(serverSideTranslations).toHaveBeenCalledWith("en");
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
async function renderIndex({
|
||||
initialSettings = { title: "Homepage", layout: {} },
|
||||
fallback = {},
|
||||
theme = "dark",
|
||||
color = "slate",
|
||||
activeTab = "",
|
||||
settings = initialSettings,
|
||||
} = {}) {
|
||||
const { default: Wrapper } = await import("pages/index.jsx");
|
||||
|
||||
const setTheme = vi.fn();
|
||||
const setColor = vi.fn();
|
||||
const setSettings = vi.fn();
|
||||
const setActiveTab = vi.fn();
|
||||
|
||||
const renderResult = render(
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
<ColorContext.Provider value={{ color, setColor }}>
|
||||
<SettingsContext.Provider value={{ settings, setSettings }}>
|
||||
<TabContext.Provider value={{ activeTab, setActiveTab }}>
|
||||
<Wrapper initialSettings={initialSettings} fallback={fallback} />
|
||||
</TabContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ColorContext.Provider>
|
||||
</ThemeContext.Provider>,
|
||||
);
|
||||
|
||||
return { ...renderResult, setTheme, setColor, setSettings, setActiveTab };
|
||||
}
|
||||
|
||||
describe("pages/index Wrapper", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.validateData = [];
|
||||
state.hashData = null;
|
||||
state.servicesData = [];
|
||||
state.bookmarksData = [];
|
||||
state.widgetsData = [];
|
||||
state.widgetCalls = [];
|
||||
document.documentElement.className = "dark theme-slate";
|
||||
});
|
||||
|
||||
it("applies theme/color classes and renders a background overlay when configured", async () => {
|
||||
await renderIndex({
|
||||
initialSettings: {
|
||||
title: "Homepage",
|
||||
color: "slate",
|
||||
background: { image: "https://example.com/bg.jpg", opacity: 10, blur: true, saturate: 150, brightness: 125 },
|
||||
layout: {},
|
||||
},
|
||||
theme: "dark",
|
||||
color: "emerald",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains("scheme-dark")).toBe(true);
|
||||
});
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
expect(document.documentElement.classList.contains("theme-emerald")).toBe(true);
|
||||
expect(document.documentElement.classList.contains("theme-slate")).toBe(false);
|
||||
|
||||
expect(document.querySelector("#background")).toBeTruthy();
|
||||
expect(document.querySelector("#inner_wrapper")?.className).toContain("backdrop-blur");
|
||||
expect(document.querySelector("#inner_wrapper")?.className).toContain("backdrop-saturate-150");
|
||||
expect(document.querySelector("#inner_wrapper")?.className).toContain("backdrop-brightness-125");
|
||||
});
|
||||
|
||||
it("supports legacy string backgrounds in settings", async () => {
|
||||
await renderIndex({
|
||||
initialSettings: {
|
||||
title: "Homepage",
|
||||
color: "slate",
|
||||
background: "https://example.com/bg.jpg",
|
||||
layout: {},
|
||||
},
|
||||
theme: "dark",
|
||||
color: "emerald",
|
||||
});
|
||||
|
||||
expect(document.querySelector("#background")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pages/index Index routing + SWR branches", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.hashData = null;
|
||||
state.mutateHash.mockClear();
|
||||
state.servicesData = [];
|
||||
state.bookmarksData = [];
|
||||
state.widgetsData = [];
|
||||
});
|
||||
|
||||
it("renders the validation error screen when /api/validate returns an error", async () => {
|
||||
state.validateData = { error: "bad config" };
|
||||
|
||||
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
|
||||
|
||||
expect(screen.getByText("Error")).toBeInTheDocument();
|
||||
expect(screen.getByText("bad config")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders config errors when /api/validate returns a list of errors", async () => {
|
||||
state.validateData = [{ config: "services.yaml", reason: "broken", mark: { snippet: "x: y" } }];
|
||||
|
||||
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
|
||||
|
||||
expect(screen.getByText("services.yaml")).toBeInTheDocument();
|
||||
expect(screen.getByText("broken")).toBeInTheDocument();
|
||||
expect(screen.getByText("x: y")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the UI stale when the hash changes and triggers a revalidate reload", async () => {
|
||||
state.validateData = [];
|
||||
state.hashData = { hash: "new-hash" };
|
||||
localStorage.setItem("hash", "old-hash");
|
||||
|
||||
const fetchSpy = vi.fn(async () => ({ ok: true }));
|
||||
// eslint-disable-next-line no-global-assign
|
||||
fetch = fetchSpy;
|
||||
|
||||
let reloadSpy;
|
||||
try {
|
||||
reloadSpy = vi.spyOn(window.location, "reload").mockImplementation(() => {});
|
||||
} catch {
|
||||
// jsdom can make window.location non-configurable in some contexts.
|
||||
Object.defineProperty(window, "location", { value: { reload: vi.fn() }, writable: true });
|
||||
reloadSpy = vi.spyOn(window.location, "reload").mockImplementation(() => {});
|
||||
}
|
||||
|
||||
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith("/api/revalidate");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
});
|
||||
expect(document.querySelector(".animate-spin")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("mutates the hash when the window regains focus", async () => {
|
||||
state.validateData = [];
|
||||
state.hashData = { hash: "h" };
|
||||
state.windowFocused = true;
|
||||
|
||||
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(state.mutateHash).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("stores the initial hash in localStorage when none exists", async () => {
|
||||
state.validateData = [];
|
||||
state.hashData = { hash: "first-hash" };
|
||||
localStorage.removeItem("hash");
|
||||
|
||||
await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("hash")).toBe("first-hash");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pages/index Home behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.validateData = [];
|
||||
state.hashData = null;
|
||||
state.servicesData = [
|
||||
{
|
||||
name: "Services",
|
||||
services: [{ name: "s1", href: "http://svc/1" }, { name: "s2" }],
|
||||
groups: [{ name: "Nested", services: [{ name: "s3", href: "http://svc/3" }], groups: [] }],
|
||||
},
|
||||
];
|
||||
state.bookmarksData = [{ name: "Bookmarks", bookmarks: [{ name: "b1", href: "http://bm/1" }, { name: "b2" }] }];
|
||||
state.widgetsData = [{ type: "glances" }, { type: "search" }];
|
||||
state.quickLaunchProps = null;
|
||||
state.widgetCalls = [];
|
||||
});
|
||||
|
||||
it("passes href-bearing services and bookmarks to QuickLaunch and toggles search on keydown", async () => {
|
||||
await renderIndex({
|
||||
initialSettings: { title: "Homepage", layout: {} },
|
||||
settings: { title: "Homepage", layout: {}, language: "en" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(state.quickLaunchProps).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(state.quickLaunchProps.servicesAndBookmarks.map((i) => i.name)).toEqual(["b1", "s1", "s3"]);
|
||||
expect(screen.getByTestId("quicklaunch")).toHaveTextContent("closed:3");
|
||||
|
||||
fireEvent.keyDown(document.body, { key: "a" });
|
||||
expect(screen.getByTestId("quicklaunch")).toHaveTextContent("open:3");
|
||||
|
||||
fireEvent.keyDown(document.body, { key: "Escape" });
|
||||
expect(screen.getByTestId("quicklaunch")).toHaveTextContent("closed:3");
|
||||
});
|
||||
|
||||
it("renders services and bookmark groups when present", async () => {
|
||||
await renderIndex({
|
||||
initialSettings: { title: "Homepage", layout: {} },
|
||||
settings: { title: "Homepage", layout: {}, language: "en" },
|
||||
});
|
||||
|
||||
expect(await screen.findByTestId("services-group")).toHaveTextContent("Services");
|
||||
expect(screen.getByTestId("bookmarks-group")).toHaveTextContent("Bookmarks");
|
||||
});
|
||||
|
||||
it("renders tab navigation and filters groups by active tab", async () => {
|
||||
state.servicesData = [{ name: "Services", services: [], groups: [] }];
|
||||
state.bookmarksData = [{ name: "Bookmarks", bookmarks: [] }];
|
||||
|
||||
await renderIndex({
|
||||
initialSettings: { title: "Homepage", layout: { Services: { tab: "Main" }, Bookmarks: { tab: "Main" } } },
|
||||
settings: { title: "Homepage", layout: { Services: { tab: "Main" }, Bookmarks: { tab: "Main" } } },
|
||||
activeTab: "main",
|
||||
});
|
||||
|
||||
expect(await screen.findAllByTestId("tab")).toHaveLength(1);
|
||||
expect(screen.getAllByTestId("services-group")[0]).toHaveTextContent("Services");
|
||||
expect(screen.getAllByTestId("bookmarks-group")[0]).toHaveTextContent("Bookmarks");
|
||||
});
|
||||
|
||||
it("waits for settings.layout to populate when it differs from initial settings", async () => {
|
||||
state.servicesData = [{ name: "Services", services: [], groups: [] }];
|
||||
state.bookmarksData = [{ name: "Bookmarks", bookmarks: [] }];
|
||||
|
||||
await renderIndex({
|
||||
initialSettings: { title: "Homepage", layout: {} },
|
||||
// Missing layout triggers the temporary `<div />` return to avoid eager widget fetches.
|
||||
settings: { title: "Homepage" },
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("services-group")).toBeNull();
|
||||
expect(screen.queryByTestId("bookmarks-group")).toBeNull();
|
||||
});
|
||||
|
||||
it("applies cardBlur classes for tabs and boxed headers when configured", async () => {
|
||||
state.servicesData = [{ name: "Services", services: [], groups: [] }];
|
||||
state.bookmarksData = [{ name: "Bookmarks", bookmarks: [] }];
|
||||
state.widgetsData = [{ type: "search" }];
|
||||
|
||||
await renderIndex({
|
||||
initialSettings: { title: "Homepage", layout: { Services: { tab: "Main" }, Bookmarks: { tab: "Main" } } },
|
||||
settings: {
|
||||
title: "Homepage",
|
||||
layout: { Services: { tab: "Main" }, Bookmarks: { tab: "Main" } },
|
||||
headerStyle: "boxed",
|
||||
cardBlur: "sm",
|
||||
},
|
||||
activeTab: "main",
|
||||
});
|
||||
|
||||
expect(document.querySelector("#myTab")?.className).toContain("backdrop-blur-sm");
|
||||
expect(document.querySelector("#information-widgets")?.className).toContain("backdrop-blur-sm");
|
||||
});
|
||||
|
||||
it("applies settings-driven language/theme/color updates and renders head tags", async () => {
|
||||
state.servicesData = [];
|
||||
state.bookmarksData = [];
|
||||
state.widgetsData = [];
|
||||
|
||||
const { setTheme, setColor, setSettings } = await renderIndex({
|
||||
initialSettings: { title: "Homepage", layout: {} },
|
||||
settings: {
|
||||
title: "Homepage",
|
||||
layout: {},
|
||||
language: "en",
|
||||
theme: "light",
|
||||
color: "emerald",
|
||||
disableIndexing: true,
|
||||
base: "/base/",
|
||||
favicon: "/x.ico",
|
||||
},
|
||||
theme: "dark",
|
||||
color: "slate",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setSettings).toHaveBeenCalled();
|
||||
});
|
||||
expect(i18n.changeLanguage).toHaveBeenCalledWith("en");
|
||||
expect(setTheme).toHaveBeenCalledWith("light");
|
||||
expect(setColor).toHaveBeenCalledWith("emerald");
|
||||
|
||||
expect(document.querySelector('meta[name="robots"][content="noindex, nofollow"]')).toBeTruthy();
|
||||
expect(document.querySelector("base")?.getAttribute("href")).toBe("/base/");
|
||||
expect(document.querySelector('link[rel="icon"]')?.getAttribute("href")).toBe("/x.ico");
|
||||
});
|
||||
|
||||
it("marks information widgets as right-aligned for known widget types", async () => {
|
||||
await renderIndex({
|
||||
initialSettings: { title: "Homepage", layout: {} },
|
||||
settings: { title: "Homepage", layout: {}, language: "en" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(state.widgetCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const rightAligned = state.widgetCalls.filter((c) => c.style?.isRightAligned).map((c) => c.widget.type);
|
||||
expect(rightAligned).toEqual(["search"]);
|
||||
});
|
||||
});
|
||||
45
src/__tests__/pages/robots.txt.test.js
Normal file
45
src/__tests__/pages/robots.txt.test.js
Normal file
@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getSettings } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
import RobotsTxt, { getServerSideProps } from "pages/robots.txt.js";
|
||||
|
||||
function createMockRes() {
|
||||
return {
|
||||
setHeader: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("pages/robots.txt", () => {
|
||||
it("allows indexing when disableIndexing is falsey", async () => {
|
||||
getSettings.mockReturnValueOnce({ disableIndexing: false });
|
||||
const res = createMockRes();
|
||||
|
||||
await getServerSideProps({ res });
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "text/plain");
|
||||
expect(res.write).toHaveBeenCalledWith("User-agent: *\nAllow: /");
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disallows indexing when disableIndexing is truthy", async () => {
|
||||
getSettings.mockReturnValueOnce({ disableIndexing: true });
|
||||
const res = createMockRes();
|
||||
|
||||
await getServerSideProps({ res });
|
||||
|
||||
expect(res.write).toHaveBeenCalledWith("User-agent: *\nDisallow: /");
|
||||
});
|
||||
|
||||
it("exports a placeholder component", () => {
|
||||
expect(RobotsTxt()).toBeNull();
|
||||
});
|
||||
});
|
||||
96
src/__tests__/pages/site.webmanifest.test.js
Normal file
96
src/__tests__/pages/site.webmanifest.test.js
Normal file
@ -0,0 +1,96 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import themes from "utils/styles/themes";
|
||||
|
||||
const { checkAndCopyConfig, getSettings } = vi.hoisted(() => ({
|
||||
checkAndCopyConfig: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
default: checkAndCopyConfig,
|
||||
getSettings,
|
||||
}));
|
||||
|
||||
import Webmanifest, { getServerSideProps } from "pages/site.webmanifest.jsx";
|
||||
|
||||
function createMockRes() {
|
||||
return {
|
||||
setHeader: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("pages/site.webmanifest", () => {
|
||||
it("writes a manifest json response and triggers a settings config check", async () => {
|
||||
getSettings.mockReturnValueOnce({
|
||||
title: "My Homepage",
|
||||
startUrl: "/start",
|
||||
color: "slate",
|
||||
theme: "dark",
|
||||
pwa: {
|
||||
icons: [{ src: "/i.png", sizes: "1x1", type: "image/png" }],
|
||||
shortcuts: [{ name: "One", url: "/one" }],
|
||||
},
|
||||
});
|
||||
|
||||
const res = createMockRes();
|
||||
|
||||
await getServerSideProps({ res });
|
||||
|
||||
expect(checkAndCopyConfig).toHaveBeenCalledWith("settings.yaml");
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "application/manifest+json");
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
|
||||
const manifest = JSON.parse(res.write.mock.calls[0][0]);
|
||||
expect(manifest.name).toBe("My Homepage");
|
||||
expect(manifest.short_name).toBe("My Homepage");
|
||||
expect(manifest.start_url).toBe("/start");
|
||||
expect(manifest.icons).toEqual([{ src: "/i.png", sizes: "1x1", type: "image/png" }]);
|
||||
expect(manifest.shortcuts).toEqual([{ name: "One", url: "/one" }]);
|
||||
expect(manifest.theme_color).toBe(themes.slate.dark);
|
||||
expect(manifest.background_color).toBe(themes.slate.dark);
|
||||
});
|
||||
|
||||
it("uses sensible defaults when no settings are provided", async () => {
|
||||
getSettings.mockReturnValueOnce({});
|
||||
|
||||
const res = createMockRes();
|
||||
|
||||
await getServerSideProps({ res });
|
||||
|
||||
const manifest = JSON.parse(res.write.mock.calls[0][0]);
|
||||
expect(manifest.name).toBe("Homepage");
|
||||
expect(manifest.short_name).toBe("Homepage");
|
||||
expect(manifest.start_url).toBe("/");
|
||||
expect(manifest.display).toBe("standalone");
|
||||
expect(manifest.theme_color).toBe(themes.slate.dark);
|
||||
expect(manifest.background_color).toBe(themes.slate.dark);
|
||||
|
||||
// Default icon set is used when pwa.icons is not set.
|
||||
expect(manifest.icons).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ src: expect.stringContaining("android-chrome-192x192") }),
|
||||
expect.objectContaining({ src: expect.stringContaining("android-chrome-512x512") }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("respects provided pwa.icons even when it is an empty array", async () => {
|
||||
getSettings.mockReturnValueOnce({
|
||||
pwa: { icons: [] },
|
||||
});
|
||||
|
||||
const res = createMockRes();
|
||||
|
||||
await getServerSideProps({ res });
|
||||
|
||||
const manifest = JSON.parse(res.write.mock.calls[0][0]);
|
||||
expect(manifest.icons).toEqual([]);
|
||||
});
|
||||
|
||||
it("exports a placeholder component", () => {
|
||||
expect(Webmanifest()).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -32,7 +32,7 @@ export default function BookmarksGroup({
|
||||
layout?.header === false ? "px-1" : "p-1 pb-0",
|
||||
)}
|
||||
>
|
||||
<Disclosure defaultOpen={!(layout?.initiallyCollapsed ?? groupsInitiallyCollapsed) ?? true}>
|
||||
<Disclosure defaultOpen={!(layout?.initiallyCollapsed ?? groupsInitiallyCollapsed)}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
{layout?.header !== false && (
|
||||
|
||||
86
src/components/bookmarks/group.test.jsx
Normal file
86
src/components/bookmarks/group.test.jsx
Normal file
@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@headlessui/react", async () => {
|
||||
const React = await import("react");
|
||||
const { Fragment } = React;
|
||||
|
||||
function Transition({ as: As = Fragment, children }) {
|
||||
if (As === Fragment) return <>{children}</>;
|
||||
return <As>{children}</As>;
|
||||
}
|
||||
|
||||
function Disclosure({ defaultOpen = true, children }) {
|
||||
const content = typeof children === "function" ? children({ open: defaultOpen }) : children;
|
||||
return <div>{content}</div>;
|
||||
}
|
||||
|
||||
function DisclosureButton(props) {
|
||||
return <button type="button" {...props} />;
|
||||
}
|
||||
|
||||
const DisclosurePanel = React.forwardRef(function DisclosurePanel(props, ref) {
|
||||
// HeadlessUI uses a boolean `static` prop; avoid forwarding it to the DOM.
|
||||
const { static: _static, ...rest } = props;
|
||||
return <div ref={ref} data-testid="disclosure-panel" {...rest} />;
|
||||
});
|
||||
|
||||
Disclosure.Button = DisclosureButton;
|
||||
Disclosure.Panel = DisclosurePanel;
|
||||
|
||||
return { Disclosure, Transition };
|
||||
});
|
||||
|
||||
vi.mock("components/bookmarks/list", () => ({
|
||||
default: function BookmarksListMock({ bookmarks }) {
|
||||
return <div data-testid="bookmarks-list">count:{bookmarks?.length ?? 0}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("components/errorboundry", () => ({
|
||||
default: function ErrorBoundaryMock({ children }) {
|
||||
return <>{children}</>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("components/resolvedicon", () => ({
|
||||
default: function ResolvedIconMock() {
|
||||
return <div data-testid="resolved-icon" />;
|
||||
},
|
||||
}));
|
||||
|
||||
import BookmarksGroup from "./group";
|
||||
|
||||
describe("components/bookmarks/group", () => {
|
||||
it("renders the group header and list", () => {
|
||||
render(
|
||||
<BookmarksGroup
|
||||
bookmarks={{ name: "Bookmarks", bookmarks: [{ name: "A" }] }}
|
||||
layout={{ icon: "mdi:test" }}
|
||||
disableCollapse={false}
|
||||
groupsInitiallyCollapsed={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Bookmarks")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("resolved-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("bookmarks-list")).toHaveTextContent("count:1");
|
||||
});
|
||||
|
||||
it("sets the panel height to 0 when initially collapsed", async () => {
|
||||
render(
|
||||
<BookmarksGroup
|
||||
bookmarks={{ name: "Bookmarks", bookmarks: [] }}
|
||||
layout={{ initiallyCollapsed: true }}
|
||||
groupsInitiallyCollapsed={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByTestId("disclosure-panel");
|
||||
await waitFor(() => {
|
||||
expect(panel.style.height).toBe("0px");
|
||||
});
|
||||
});
|
||||
});
|
||||
95
src/components/bookmarks/group.transition.test.jsx
Normal file
95
src/components/bookmarks/group.transition.test.jsx
Normal file
@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@headlessui/react", async () => {
|
||||
const React = await import("react");
|
||||
const { Fragment, useEffect } = React;
|
||||
|
||||
function Transition({ as: As = Fragment, beforeEnter, beforeLeave, children }) {
|
||||
useEffect(() => {
|
||||
beforeEnter?.();
|
||||
setTimeout(() => beforeLeave?.(), 200);
|
||||
}, [beforeEnter, beforeLeave]);
|
||||
|
||||
if (As === Fragment) return <>{children}</>;
|
||||
return <As>{children}</As>;
|
||||
}
|
||||
|
||||
function Disclosure({ defaultOpen = true, children }) {
|
||||
const content = typeof children === "function" ? children({ open: defaultOpen }) : children;
|
||||
return <div>{content}</div>;
|
||||
}
|
||||
|
||||
function DisclosureButton(props) {
|
||||
return <button type="button" {...props} />;
|
||||
}
|
||||
|
||||
const DisclosurePanel = React.forwardRef(function DisclosurePanel(props, ref) {
|
||||
const { static: _static, ...rest } = props;
|
||||
return (
|
||||
<div
|
||||
ref={(node) => {
|
||||
if (node) Object.defineProperty(node, "scrollHeight", { value: 50, configurable: true });
|
||||
if (typeof ref === "function") ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
}}
|
||||
data-testid="disclosure-panel"
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Disclosure.Button = DisclosureButton;
|
||||
Disclosure.Panel = DisclosurePanel;
|
||||
|
||||
return { Disclosure, Transition };
|
||||
});
|
||||
|
||||
vi.mock("components/bookmarks/list", () => ({
|
||||
default: function BookmarksListMock() {
|
||||
return <div data-testid="bookmarks-list" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("components/errorboundry", () => ({
|
||||
default: function ErrorBoundaryMock({ children }) {
|
||||
return <>{children}</>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("components/resolvedicon", () => ({
|
||||
default: function ResolvedIconMock() {
|
||||
return <div data-testid="resolved-icon" />;
|
||||
},
|
||||
}));
|
||||
|
||||
import BookmarksGroup from "./group";
|
||||
|
||||
describe("components/bookmarks/group transition hooks", () => {
|
||||
it("runs the Transition beforeEnter/beforeLeave height calculations and applies maxGroupColumns", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(
|
||||
<BookmarksGroup
|
||||
bookmarks={{ name: "Bookmarks", bookmarks: [] }}
|
||||
layout={{ initiallyCollapsed: false }}
|
||||
groupsInitiallyCollapsed={false}
|
||||
maxGroupColumns="7"
|
||||
/>,
|
||||
);
|
||||
|
||||
const wrapper = screen.getByText("Bookmarks").closest(".bookmark-group");
|
||||
expect(wrapper?.className).toContain("3xl:basis-1/7");
|
||||
|
||||
const panel = screen.getByTestId("disclosure-panel");
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(panel.style.height).toBe("0px");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
41
src/components/bookmarks/item.test.jsx
Normal file
41
src/components/bookmarks/item.test.jsx
Normal file
@ -0,0 +1,41 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
vi.mock("components/resolvedicon", () => ({
|
||||
default: ({ icon }) => <div data-testid="resolved-icon" data-icon={icon} />,
|
||||
}));
|
||||
|
||||
import Item from "./item";
|
||||
|
||||
describe("components/bookmarks/item", () => {
|
||||
it("falls back description to href hostname and uses settings.target", () => {
|
||||
renderWithProviders(<Item bookmark={{ name: "A", href: "http://example.com/x", abbr: "A" }} iconOnly={false} />, {
|
||||
settings: { target: "_self", cardBlur: "" },
|
||||
});
|
||||
|
||||
expect(screen.getByText("example.com")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link").getAttribute("target")).toBe("_self");
|
||||
});
|
||||
|
||||
it("renders icon-only layout with icon when provided", () => {
|
||||
renderWithProviders(
|
||||
<Item bookmark={{ name: "A", href: "http://example.com/x", abbr: "A", icon: "mdi-home" }} iconOnly />,
|
||||
{ settings: { target: "_self" } },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("resolved-icon").getAttribute("data-icon")).toBe("mdi-home");
|
||||
});
|
||||
|
||||
it("renders the non-icon-only layout with an icon when provided", () => {
|
||||
renderWithProviders(
|
||||
<Item bookmark={{ name: "A", href: "http://example.com/x", abbr: "A", icon: "mdi-home" }} iconOnly={false} />,
|
||||
{ settings: { target: "_self", cardBlur: "" } },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("resolved-icon").getAttribute("data-icon")).toBe("mdi-home");
|
||||
});
|
||||
});
|
||||
38
src/components/bookmarks/list.test.jsx
Normal file
38
src/components/bookmarks/list.test.jsx
Normal file
@ -0,0 +1,38 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { Item } = vi.hoisted(() => ({
|
||||
Item: vi.fn(({ bookmark, iconOnly }) => (
|
||||
<li data-testid="bookmark-item" data-name={bookmark.name} data-icononly={String(iconOnly)} />
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("components/bookmarks/item", () => ({
|
||||
default: Item,
|
||||
}));
|
||||
|
||||
import List from "./list";
|
||||
|
||||
describe("components/bookmarks/list", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders items with iconOnly when iconsOnly is set", () => {
|
||||
render(<List bookmarks={[{ name: "A", href: "http://a" }]} layout={{ iconsOnly: true }} bookmarksStyle="text" />);
|
||||
|
||||
expect(Item).toHaveBeenCalled();
|
||||
expect(Item.mock.calls[0][0].iconOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("applies gridTemplateColumns in icons style", () => {
|
||||
const { container } = render(
|
||||
<List bookmarks={[{ name: "A", href: "http://a" }]} layout={{ header: false }} bookmarksStyle="icons" />,
|
||||
);
|
||||
|
||||
const ul = container.querySelector("ul");
|
||||
expect(ul.style.gridTemplateColumns).toContain("minmax(60px");
|
||||
});
|
||||
});
|
||||
38
src/components/errorboundry.test.jsx
Normal file
38
src/components/errorboundry.test.jsx
Normal file
@ -0,0 +1,38 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ErrorBoundary from "./errorboundry";
|
||||
|
||||
describe("components/errorboundry", () => {
|
||||
it("renders children when no error is thrown", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>ok</div>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("ok")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a fallback UI when a child throws", () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const Boom = () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Boom />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Something went wrong.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Error: boom")).toBeInTheDocument();
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
74
src/components/favicon.test.jsx
Normal file
74
src/components/favicon.test.jsx
Normal file
@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ColorContext } from "utils/contexts/color";
|
||||
|
||||
import Favicon from "./favicon";
|
||||
|
||||
describe("components/favicon", () => {
|
||||
beforeEach(() => {
|
||||
document.head.querySelectorAll('link[rel="shortcut icon"]').forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
it("appends a shortcut icon link after rendering the SVG to canvas", async () => {
|
||||
const drawImage = vi.fn();
|
||||
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ drawImage });
|
||||
const toDataURLSpy = vi
|
||||
.spyOn(HTMLCanvasElement.prototype, "toDataURL")
|
||||
.mockReturnValue("data:image/x-icon;base64,AAA");
|
||||
|
||||
const { container } = render(
|
||||
<ColorContext.Provider value={{ color: "slate", setColor: vi.fn() }}>
|
||||
<Favicon />
|
||||
</ColorContext.Provider>,
|
||||
);
|
||||
|
||||
const img = container.querySelector("img");
|
||||
await waitFor(() => {
|
||||
expect(typeof img.onload).toBe("function");
|
||||
});
|
||||
|
||||
img.onload();
|
||||
|
||||
const link = document.head.querySelector('link[rel="shortcut icon"]');
|
||||
expect(link).not.toBeNull();
|
||||
expect(link.getAttribute("href")).toBe("data:image/x-icon;base64,AAA");
|
||||
expect(drawImage).toHaveBeenCalled();
|
||||
|
||||
getContextSpy.mockRestore();
|
||||
toDataURLSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns early when refs are missing (defensive guard)", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
return {
|
||||
...actual,
|
||||
// Run the effect immediately to hit the defensive guard before refs are attached.
|
||||
useEffect: (fn) => fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { ColorContext: TestColorContext } = await import("utils/contexts/color");
|
||||
const { default: FaviconWithMissingRefs } = await import("./favicon");
|
||||
|
||||
const { container } = render(
|
||||
<TestColorContext.Provider value={{ color: "slate", setColor: vi.fn() }}>
|
||||
<FaviconWithMissingRefs />
|
||||
</TestColorContext.Provider>,
|
||||
);
|
||||
|
||||
// Allow effects to flush; the guard should prevent the icon link from being appended.
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector("img")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(document.head.querySelector('link[rel="shortcut icon"]')).toBeNull();
|
||||
|
||||
vi.unmock("react");
|
||||
vi.resetModules();
|
||||
});
|
||||
});
|
||||
390
src/components/quicklaunch.test.jsx
Normal file
390
src/components/quicklaunch.test.jsx
Normal file
@ -0,0 +1,390 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { state, useSWR, getStoredProvider } = vi.hoisted(() => ({
|
||||
state: {
|
||||
widgets: {},
|
||||
},
|
||||
useSWR: vi.fn((key) => {
|
||||
if (key === "/api/widgets") return { data: state.widgets, error: undefined };
|
||||
return { data: undefined, error: undefined };
|
||||
}),
|
||||
getStoredProvider: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: useSWR,
|
||||
}));
|
||||
|
||||
vi.mock("./resolvedicon", () => ({
|
||||
default: function ResolvedIconMock() {
|
||||
return <div data-testid="resolved-icon" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./widgets/search/search", () => ({
|
||||
getStoredProvider,
|
||||
searchProviders: {
|
||||
duckduckgo: {
|
||||
name: "DuckDuckGo",
|
||||
url: "https://duckduckgo.example/?q=",
|
||||
suggestionUrl: "https://duckduckgo.example/ac/?q=",
|
||||
target: "_self",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import QuickLaunch from "./quicklaunch";
|
||||
|
||||
function Wrapper({ servicesAndBookmarks = [], initialOpen = true } = {}) {
|
||||
const [searchString, setSearchString] = useState("");
|
||||
const [isOpen, setSearching] = useState(initialOpen);
|
||||
|
||||
return (
|
||||
<QuickLaunch
|
||||
servicesAndBookmarks={servicesAndBookmarks}
|
||||
searchString={searchString}
|
||||
setSearchString={setSearchString}
|
||||
isOpen={isOpen}
|
||||
setSearching={setSearching}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("components/quicklaunch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.widgets = {};
|
||||
});
|
||||
|
||||
it("uses a custom provider from quicklaunch settings when configured", async () => {
|
||||
renderWithProviders(<Wrapper />, {
|
||||
settings: {
|
||||
quicklaunch: {
|
||||
provider: "custom",
|
||||
name: "MySearch",
|
||||
url: "https://custom.example/?q=",
|
||||
showSearchSuggestions: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText("Search");
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
|
||||
expect(await screen.findByText("MySearch quicklaunch.search")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the search widget's custom provider configuration when quicklaunch settings are not provided", async () => {
|
||||
state.widgets = {
|
||||
w: {
|
||||
type: "search",
|
||||
options: { provider: "custom", name: "WidgetSearch", url: "https://widget.example/?q=" },
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(<Wrapper />, { settings: { quicklaunch: { showSearchSuggestions: false } } });
|
||||
|
||||
const input = screen.getByPlaceholderText("Search");
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
|
||||
expect(await screen.findByText("WidgetSearch quicklaunch.search")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the search widget's provider setting when quicklaunch settings are not provided", async () => {
|
||||
state.widgets = {
|
||||
w: {
|
||||
type: "search",
|
||||
options: { provider: "duckduckgo" },
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(<Wrapper />, { settings: { quicklaunch: { showSearchSuggestions: false } } });
|
||||
|
||||
const input = screen.getByPlaceholderText("Search");
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
|
||||
expect(await screen.findByText("DuckDuckGo quicklaunch.search")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders results for urls and opens the selected result on Enter", async () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
|
||||
renderWithProviders(<Wrapper />, {
|
||||
settings: {
|
||||
target: "_self",
|
||||
quicklaunch: {
|
||||
provider: "duckduckgo",
|
||||
showSearchSuggestions: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText("Search");
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
|
||||
fireEvent.change(input, { target: { value: "example.com" } });
|
||||
|
||||
expect(await screen.findByText("quicklaunch.visit URL")).toBeInTheDocument();
|
||||
expect(screen.getByText("DuckDuckGo quicklaunch.search")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await act(async () => {
|
||||
// Close/reset schedules timeouts (200ms + 300ms); flush them to avoid state updates after cleanup.
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith("https://example.com/", "_self", "noreferrer");
|
||||
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("closes on Escape and clears the search string after the timeout", async () => {
|
||||
renderWithProviders(<Wrapper />, {
|
||||
settings: {
|
||||
quicklaunch: {
|
||||
provider: "duckduckgo",
|
||||
showSearchSuggestions: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText("Search");
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
expect(input).toHaveValue("abc");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("supports ArrowUp/ArrowDown navigation and opens a result on click", async () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
|
||||
renderWithProviders(
|
||||
<Wrapper
|
||||
servicesAndBookmarks={[
|
||||
{ name: "Alpha", href: "https://alpha.example", icon: "mdi:test" },
|
||||
{ name: "Alpine", href: "https://alpine.example" },
|
||||
]}
|
||||
/>,
|
||||
{ settings: { target: "_self", quicklaunch: { showSearchSuggestions: false } } },
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search");
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
|
||||
fireEvent.change(input, { target: { value: "al" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('button[data-index="0"]')).toBeTruthy();
|
||||
expect(document.querySelector('button[data-index="1"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Icon/abbr container renders when icon is present.
|
||||
expect(screen.getByTestId("resolved-icon")).toBeInTheDocument();
|
||||
|
||||
const button0 = document.querySelector('button[data-index="0"]');
|
||||
const button1 = document.querySelector('button[data-index="1"]');
|
||||
expect(button0.className).toContain("bg-theme-300/50");
|
||||
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
expect(button1.className).toContain("bg-theme-300/50");
|
||||
|
||||
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||
expect(button0.className).toContain("bg-theme-300/50");
|
||||
|
||||
fireEvent.click(button0);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith("https://alpha.example", "_self", "noreferrer");
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("handles Escape on a result button (not just the input)", async () => {
|
||||
renderWithProviders(<Wrapper servicesAndBookmarks={[{ name: "Alpha", href: "https://alpha.example" }]} />, {
|
||||
settings: { quicklaunch: { showSearchSuggestions: false } },
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText("Search");
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
|
||||
fireEvent.change(input, { target: { value: "al" } });
|
||||
await waitFor(() => expect(document.querySelector('button[data-index="0"]')).toBeTruthy());
|
||||
const button0 = document.querySelector('button[data-index="0"]');
|
||||
|
||||
button0.focus();
|
||||
fireEvent.keyDown(button0, { key: "Escape" });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("highlights matching description text when searchDescriptions is enabled", async () => {
|
||||
renderWithProviders(
|
||||
<Wrapper
|
||||
servicesAndBookmarks={[
|
||||
{ name: "Unrelated", description: "This has MatchMe inside", href: "https://example.com" },
|
||||
]}
|
||||
/>,
|
||||
{
|
||||
settings: {
|
||||
quicklaunch: {
|
||||
provider: "duckduckgo",
|
||||
searchDescriptions: true,
|
||||
showSearchSuggestions: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search");
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
|
||||
fireEvent.change(input, { target: { value: "matchme" } });
|
||||
|
||||
// A description-only match uses highlightText (bg-theme-300/10).
|
||||
const highlight = await screen.findByText(/matchme/i);
|
||||
expect(highlight.closest("span")?.className).toContain("bg-theme-300/10");
|
||||
});
|
||||
|
||||
it("fetches search suggestions and ArrowRight autocompletes the selected suggestion", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchSpy = vi.fn(async () => ({
|
||||
json: async () => ["test", ["test 1", "test 2", "test 3", "test 4", "test 5"]],
|
||||
}));
|
||||
// eslint-disable-next-line no-global-assign
|
||||
fetch = fetchSpy;
|
||||
|
||||
renderWithProviders(<Wrapper />, {
|
||||
settings: {
|
||||
quicklaunch: {
|
||||
provider: "duckduckgo",
|
||||
showSearchSuggestions: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText("Search");
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
|
||||
fireEvent.change(input, { target: { value: "test" } });
|
||||
|
||||
// Suggestions are fetched via the API route.
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/search/searchSuggestion?query=test"),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("quicklaunch.searchsuggestion").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const suggestionButton = Array.from(document.querySelectorAll("button")).find((btn) =>
|
||||
btn.textContent?.includes("test 1"),
|
||||
);
|
||||
expect(suggestionButton).toBeTruthy();
|
||||
fireEvent.mouseEnter(suggestionButton);
|
||||
fireEvent.keyDown(input, { key: "ArrowRight" });
|
||||
|
||||
expect(input).toHaveValue("test 1");
|
||||
|
||||
// eslint-disable-next-line no-global-assign
|
||||
fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("uses the stored provider when the search widget provides a provider list", async () => {
|
||||
state.widgets = {
|
||||
w: {
|
||||
type: "search",
|
||||
options: { provider: ["duckduckgo"] },
|
||||
},
|
||||
};
|
||||
getStoredProvider.mockReturnValue({
|
||||
name: "StoredProvider",
|
||||
url: "https://stored.example/?q=",
|
||||
suggestionUrl: "https://stored.example/ac/?q=",
|
||||
});
|
||||
|
||||
renderWithProviders(<Wrapper />, { settings: { quicklaunch: { showSearchSuggestions: false } } });
|
||||
|
||||
const input = screen.getByPlaceholderText("Search");
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
|
||||
expect(await screen.findByText("StoredProvider quicklaunch.search")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the mobile button when configured and opens the dialog when clicked", async () => {
|
||||
renderWithProviders(<Wrapper initialOpen={false} />, {
|
||||
settings: {
|
||||
quicklaunch: {
|
||||
mobileButtonPosition: "top-right",
|
||||
provider: "duckduckgo",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mobileButton = screen.getByRole("button", { name: "" });
|
||||
expect(mobileButton.className).toContain("top-4 right-4");
|
||||
|
||||
fireEvent.click(mobileButton);
|
||||
const input = await screen.findByPlaceholderText("Search");
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
});
|
||||
|
||||
it("closes when the backdrop is clicked and clears the search string after the timeout", async () => {
|
||||
renderWithProviders(<Wrapper />, {
|
||||
settings: {
|
||||
quicklaunch: {
|
||||
provider: "duckduckgo",
|
||||
showSearchSuggestions: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText("Search");
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
|
||||
fireEvent.change(input, { target: { value: "example.com" } });
|
||||
expect(input).toHaveValue("example.com");
|
||||
|
||||
// The backdrop is a DIV; clicking it should close and schedule a reset.
|
||||
const backdrop = document.querySelector(".fixed.inset-0.bg-gray-500.opacity-50");
|
||||
expect(backdrop).toBeTruthy();
|
||||
fireEvent.click(backdrop);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
});
|
||||
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
82
src/components/resolvedicon.test.jsx
Normal file
82
src/components/resolvedicon.test.jsx
Normal file
@ -0,0 +1,82 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SettingsContext } from "utils/contexts/settings";
|
||||
import { ThemeContext } from "utils/contexts/theme";
|
||||
|
||||
vi.mock("next/image", () => ({
|
||||
default: ({ src, alt }) => <div data-testid="next-image" data-src={src} data-alt={alt} />,
|
||||
}));
|
||||
|
||||
import ResolvedIcon from "./resolvedicon";
|
||||
|
||||
function renderWithContexts(ui, { settings = {}, theme = "dark" } = {}) {
|
||||
return render(
|
||||
<SettingsContext.Provider value={{ settings, setSettings: () => {} }}>
|
||||
<ThemeContext.Provider value={{ theme, setTheme: vi.fn() }}>{ui}</ThemeContext.Provider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("components/resolvedicon", () => {
|
||||
it("renders direct URL icons via next/image", () => {
|
||||
renderWithContexts(<ResolvedIcon icon="http://example.com/x.png" alt="x" />);
|
||||
expect(screen.getByTestId("next-image").getAttribute("data-src")).toBe("http://example.com/x.png");
|
||||
});
|
||||
|
||||
it("renders relative URL icons via next/image", () => {
|
||||
renderWithContexts(<ResolvedIcon icon="/icons/x.png" alt="x" />);
|
||||
expect(screen.getByTestId("next-image").getAttribute("data-src")).toBe("/icons/x.png");
|
||||
});
|
||||
|
||||
it("renders selfh.st icons for sh- prefix with extension", () => {
|
||||
renderWithContexts(<ResolvedIcon icon="sh-test.webp" alt="x" />);
|
||||
expect(screen.getByTestId("next-image").getAttribute("data-src")).toContain("/webp/test.webp");
|
||||
});
|
||||
|
||||
it("renders selfh.st icons as svg or png based on file extension", () => {
|
||||
renderWithContexts(<ResolvedIcon icon="sh-test.svg" alt="x" />);
|
||||
expect(screen.getByTestId("next-image").getAttribute("data-src")).toContain("/svg/test.svg");
|
||||
|
||||
renderWithContexts(<ResolvedIcon icon="sh-test.png" alt="x" />);
|
||||
expect(screen.getAllByTestId("next-image")[1].getAttribute("data-src")).toContain("/png/test.png");
|
||||
});
|
||||
|
||||
it("renders mdi icons as a masked div and supports custom hex colors", () => {
|
||||
const { container } = renderWithContexts(<ResolvedIcon icon="mdi-home-#ff00ff" />, {
|
||||
settings: { iconStyle: "theme" },
|
||||
theme: "dark",
|
||||
});
|
||||
|
||||
const div = container.querySelector("div");
|
||||
// Browser normalizes hex colors to rgb() strings on assignment.
|
||||
expect(div.style.background).toMatch(/(#ff00ff|rgb\(255, 0, 255\))/);
|
||||
expect(div.getAttribute("style")).toContain("home.svg");
|
||||
});
|
||||
|
||||
it("renders si icons with a masked div using the configured icon style", () => {
|
||||
const { container } = renderWithContexts(<ResolvedIcon icon="si-github" />, {
|
||||
settings: { iconStyle: "gradient" },
|
||||
theme: "light",
|
||||
});
|
||||
|
||||
const div = container.querySelector("div");
|
||||
expect(div.getAttribute("style")).toContain("github.svg");
|
||||
expect(div.style.background).toContain("linear-gradient");
|
||||
});
|
||||
|
||||
it("falls back to dashboard-icons for .svg", () => {
|
||||
renderWithContexts(<ResolvedIcon icon="foo.svg" />);
|
||||
expect(screen.getByTestId("next-image").getAttribute("data-src")).toContain("/dashboard-icons/svg/foo.svg");
|
||||
});
|
||||
|
||||
it("falls back to dashboard-icons for .webp and .png", () => {
|
||||
renderWithContexts(<ResolvedIcon icon="foo.webp" />);
|
||||
expect(screen.getAllByTestId("next-image")[0].getAttribute("data-src")).toContain("/dashboard-icons/webp/foo.webp");
|
||||
|
||||
renderWithContexts(<ResolvedIcon icon="foo.png" />);
|
||||
expect(screen.getAllByTestId("next-image")[1].getAttribute("data-src")).toContain("/dashboard-icons/png/foo.png");
|
||||
});
|
||||
});
|
||||
56
src/components/services/dropdown.test.jsx
Normal file
56
src/components/services/dropdown.test.jsx
Normal file
@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Stub Menu/Transition to always render children (keeps tests deterministic).
|
||||
vi.mock("@headlessui/react", async () => {
|
||||
const React = await import("react");
|
||||
const { Fragment } = React;
|
||||
|
||||
function Transition({ as: As = Fragment, children }) {
|
||||
if (As === Fragment) return <>{children}</>;
|
||||
return <As>{children}</As>;
|
||||
}
|
||||
|
||||
function Menu({ as: As = "div", children, ...props }) {
|
||||
const content = typeof children === "function" ? children({ open: true }) : children;
|
||||
return <As {...props}>{content}</As>;
|
||||
}
|
||||
|
||||
function MenuButton(props) {
|
||||
return <button type="button" {...props} />;
|
||||
}
|
||||
function MenuItems(props) {
|
||||
return <div {...props} />;
|
||||
}
|
||||
function MenuItem({ children }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
Menu.Button = MenuButton;
|
||||
Menu.Items = MenuItems;
|
||||
Menu.Item = MenuItem;
|
||||
|
||||
return { Menu, Transition };
|
||||
});
|
||||
|
||||
import Dropdown from "./dropdown";
|
||||
|
||||
describe("components/services/dropdown", () => {
|
||||
it("renders the selected label and updates value when an option is clicked", () => {
|
||||
const setValue = vi.fn();
|
||||
const options = [
|
||||
{ value: "a", label: "Alpha" },
|
||||
{ value: "b", label: "Beta" },
|
||||
];
|
||||
|
||||
render(<Dropdown options={options} value="a" setValue={setValue} />);
|
||||
|
||||
// "Alpha" appears both in the menu button and in the list of options.
|
||||
expect(screen.getAllByRole("button", { name: "Alpha" })[0]).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Beta" }));
|
||||
expect(setValue).toHaveBeenCalledWith("b");
|
||||
});
|
||||
});
|
||||
@ -36,7 +36,7 @@ export default function ServicesGroup({
|
||||
isSubgroup ? "subgroup" : "",
|
||||
)}
|
||||
>
|
||||
<Disclosure defaultOpen={!(layout?.initiallyCollapsed ?? groupsInitiallyCollapsed) ?? true}>
|
||||
<Disclosure defaultOpen={!(layout?.initiallyCollapsed ?? groupsInitiallyCollapsed)}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
{layout?.header !== false && (
|
||||
|
||||
87
src/components/services/group.test.jsx
Normal file
87
src/components/services/group.test.jsx
Normal file
@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@headlessui/react", async () => {
|
||||
const React = await import("react");
|
||||
const { Fragment } = React;
|
||||
|
||||
function Transition({ as: As = Fragment, children }) {
|
||||
if (As === Fragment) return <>{children}</>;
|
||||
return <As>{children}</As>;
|
||||
}
|
||||
|
||||
function Disclosure({ defaultOpen = true, children }) {
|
||||
const content = typeof children === "function" ? children({ open: defaultOpen }) : children;
|
||||
return <div>{content}</div>;
|
||||
}
|
||||
|
||||
function DisclosureButton(props) {
|
||||
return <button type="button" {...props} />;
|
||||
}
|
||||
|
||||
const DisclosurePanel = React.forwardRef(function DisclosurePanel(props, ref) {
|
||||
return <div ref={ref} data-testid="disclosure-panel" {...props} static="true" />;
|
||||
});
|
||||
|
||||
Disclosure.Button = DisclosureButton;
|
||||
Disclosure.Panel = DisclosurePanel;
|
||||
|
||||
return { Disclosure, Transition };
|
||||
});
|
||||
|
||||
vi.mock("components/resolvedicon", () => ({
|
||||
default: function ResolvedIconMock() {
|
||||
return <div data-testid="resolved-icon" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("components/services/list", () => ({
|
||||
default: function ServicesListMock({ groupName, services }) {
|
||||
return (
|
||||
<div data-testid="services-list-mock">
|
||||
{groupName}:{services?.length ?? 0}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import ServicesGroup from "./group";
|
||||
|
||||
describe("components/services/group", () => {
|
||||
it("renders group and subgroup headers", () => {
|
||||
render(
|
||||
<ServicesGroup
|
||||
group={{
|
||||
name: "Main",
|
||||
services: [{ name: "svc" }],
|
||||
groups: [{ name: "Sub", services: [], groups: [] }],
|
||||
}}
|
||||
layout={{ icon: "mdi:test" }}
|
||||
groupsInitiallyCollapsed={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Main")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("resolved-icon")).toBeInTheDocument();
|
||||
const lists = screen.getAllByTestId("services-list-mock");
|
||||
expect(lists[0]).toHaveTextContent("Main:1");
|
||||
expect(screen.getByText("Sub")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets the panel height to 0 when initially collapsed", async () => {
|
||||
render(
|
||||
<ServicesGroup
|
||||
group={{ name: "Main", services: [], groups: [] }}
|
||||
layout={{ initiallyCollapsed: true }}
|
||||
groupsInitiallyCollapsed={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getAllByTestId("disclosure-panel")[0];
|
||||
await waitFor(() => {
|
||||
expect(panel.style.height).toBe("0px");
|
||||
});
|
||||
});
|
||||
});
|
||||
92
src/components/services/group.transition.test.jsx
Normal file
92
src/components/services/group.transition.test.jsx
Normal file
@ -0,0 +1,92 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@headlessui/react", async () => {
|
||||
const React = await import("react");
|
||||
const { Fragment, useEffect } = React;
|
||||
|
||||
function Transition({ as: As = Fragment, beforeEnter, beforeLeave, children }) {
|
||||
useEffect(() => {
|
||||
// Simulate a mount -> enter animation, then a leave animation shortly after.
|
||||
beforeEnter?.();
|
||||
setTimeout(() => beforeLeave?.(), 200);
|
||||
}, [beforeEnter, beforeLeave]);
|
||||
|
||||
if (As === Fragment) return <>{children}</>;
|
||||
return <As>{children}</As>;
|
||||
}
|
||||
|
||||
function Disclosure({ defaultOpen = true, children }) {
|
||||
const content = typeof children === "function" ? children({ open: defaultOpen }) : children;
|
||||
return <div>{content}</div>;
|
||||
}
|
||||
|
||||
function DisclosureButton(props) {
|
||||
return <button type="button" {...props} />;
|
||||
}
|
||||
|
||||
const DisclosurePanel = React.forwardRef(function DisclosurePanel(props, ref) {
|
||||
const { static: _static, ...rest } = props;
|
||||
return (
|
||||
<div
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
// JSDOM doesn't calculate layout; give the panel a deterministic height.
|
||||
Object.defineProperty(node, "scrollHeight", { value: 123, configurable: true });
|
||||
}
|
||||
if (typeof ref === "function") ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
}}
|
||||
data-testid="disclosure-panel"
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Disclosure.Button = DisclosureButton;
|
||||
Disclosure.Panel = DisclosurePanel;
|
||||
|
||||
return { Disclosure, Transition };
|
||||
});
|
||||
|
||||
vi.mock("components/resolvedicon", () => ({
|
||||
default: function ResolvedIconMock() {
|
||||
return <div data-testid="resolved-icon" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("components/services/list", () => ({
|
||||
default: function ServicesListMock() {
|
||||
return <div data-testid="services-list" />;
|
||||
},
|
||||
}));
|
||||
|
||||
import ServicesGroup from "./group";
|
||||
|
||||
describe("components/services/group transition hooks", () => {
|
||||
it("runs the Transition beforeEnter/beforeLeave height calculations", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(
|
||||
<ServicesGroup
|
||||
group={{ name: "Main", services: [], groups: [] }}
|
||||
layout={{ initiallyCollapsed: false }}
|
||||
groupsInitiallyCollapsed={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByTestId("disclosure-panel");
|
||||
expect(panel).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
// The leave animation sets height back to 0.
|
||||
expect(panel.style.height).toBe("0px");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
247
src/components/services/item.test.jsx
Normal file
247
src/components/services/item.test.jsx
Normal file
@ -0,0 +1,247 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
vi.mock("components/resolvedicon", () => ({
|
||||
default: function ResolvedIconMock() {
|
||||
return <div data-testid="resolved-icon" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("widgets/docker/component", () => ({
|
||||
default: function DockerWidgetMock() {
|
||||
return <div data-testid="docker-widget" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("widgets/kubernetes/component", () => ({
|
||||
default: function KubernetesWidgetMock() {
|
||||
return <div data-testid="kubernetes-widget" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("widgets/proxmoxvm/component", () => ({
|
||||
default: function ProxmoxVMWidgetMock() {
|
||||
return <div data-testid="proxmoxvm-widget" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./ping", () => ({
|
||||
default: function PingMock() {
|
||||
return <div data-testid="ping" />;
|
||||
},
|
||||
}));
|
||||
vi.mock("./site-monitor", () => ({
|
||||
default: function SiteMonitorMock() {
|
||||
return <div data-testid="site-monitor" />;
|
||||
},
|
||||
}));
|
||||
vi.mock("./status", () => ({
|
||||
default: function StatusMock() {
|
||||
return <div data-testid="status" />;
|
||||
},
|
||||
}));
|
||||
vi.mock("./kubernetes-status", () => ({
|
||||
default: function KubernetesStatusMock() {
|
||||
return <div data-testid="kubernetes-status" />;
|
||||
},
|
||||
}));
|
||||
vi.mock("./proxmox-status", () => ({
|
||||
default: function ProxmoxStatusMock() {
|
||||
return <div data-testid="proxmox-status" />;
|
||||
},
|
||||
}));
|
||||
vi.mock("./widget", () => ({
|
||||
default: function ServiceWidgetMock({ widget }) {
|
||||
return <div data-testid="service-widget">idx:{widget.index}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
import Item from "./item";
|
||||
|
||||
describe("components/services/item", () => {
|
||||
it("renders the service title as a link when href is provided", () => {
|
||||
renderWithProviders(
|
||||
<Item
|
||||
groupName="G"
|
||||
useEqualHeights={false}
|
||||
service={{
|
||||
id: "svc1",
|
||||
name: "My Service",
|
||||
description: "Desc",
|
||||
href: "https://example.com",
|
||||
icon: "mdi:test",
|
||||
widgets: [],
|
||||
}}
|
||||
/>,
|
||||
{ settings: { target: "_self", showStats: false, statusStyle: "basic" } },
|
||||
);
|
||||
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links.some((l) => l.getAttribute("href") === "https://example.com")).toBe(true);
|
||||
expect(screen.getByText("My Service")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the icon without a link when href is missing or '#'", () => {
|
||||
renderWithProviders(
|
||||
<Item
|
||||
groupName="G"
|
||||
useEqualHeights={false}
|
||||
service={{
|
||||
id: "svc1",
|
||||
name: "My Service",
|
||||
description: "Desc",
|
||||
href: "#",
|
||||
icon: "mdi:test",
|
||||
widgets: [],
|
||||
}}
|
||||
/>,
|
||||
{ settings: { target: "_self", showStats: false, statusStyle: "basic" } },
|
||||
);
|
||||
|
||||
// The title area should not create a clickable href="#" link.
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("resolved-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles container stats on click when stats are hidden by default", () => {
|
||||
renderWithProviders(
|
||||
<Item
|
||||
groupName="G"
|
||||
useEqualHeights={false}
|
||||
service={{
|
||||
id: "svc1",
|
||||
name: "My Service",
|
||||
description: "Desc",
|
||||
href: "https://example.com",
|
||||
container: "c",
|
||||
server: "s",
|
||||
ping: true,
|
||||
siteMonitor: true,
|
||||
widgets: [{ index: 1 }, { index: 2 }],
|
||||
}}
|
||||
/>,
|
||||
{ settings: { showStats: false, statusStyle: "basic" } },
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("docker-widget")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("ping")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("site-monitor")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "View container stats" }));
|
||||
expect(screen.getByTestId("docker-widget")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getAllByTestId("service-widget")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows stats by default when settings.showStats is enabled, unless overridden by the service", () => {
|
||||
const baseService = {
|
||||
id: "svc1",
|
||||
name: "My Service",
|
||||
description: "Desc",
|
||||
container: "c",
|
||||
server: "s",
|
||||
widgets: [],
|
||||
};
|
||||
|
||||
renderWithProviders(<Item groupName="G" useEqualHeights={false} service={baseService} />, {
|
||||
settings: { showStats: true, statusStyle: "basic" },
|
||||
});
|
||||
expect(screen.getByTestId("docker-widget")).toBeInTheDocument();
|
||||
|
||||
renderWithProviders(
|
||||
<Item groupName="G" useEqualHeights={false} service={{ ...baseService, id: "svc2", showStats: false }} />,
|
||||
{
|
||||
settings: { showStats: true, statusStyle: "basic" },
|
||||
},
|
||||
);
|
||||
expect(screen.getAllByTestId("docker-widget")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("closes stats after a short delay when toggled closed", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
renderWithProviders(
|
||||
<Item
|
||||
groupName="G"
|
||||
useEqualHeights={false}
|
||||
service={{
|
||||
id: "svc1",
|
||||
name: "My Service",
|
||||
description: "Desc",
|
||||
container: "c",
|
||||
server: "s",
|
||||
widgets: [],
|
||||
}}
|
||||
/>,
|
||||
{ settings: { showStats: false, statusStyle: "basic" } },
|
||||
);
|
||||
|
||||
const btn = screen.getByRole("button", { name: "View container stats" });
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByTestId("docker-widget")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(btn);
|
||||
// Still rendered while the close animation runs.
|
||||
expect(screen.getByTestId("docker-widget")).toBeInTheDocument();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(screen.queryByTestId("docker-widget")).not.toBeInTheDocument();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("toggles app and proxmox stats using their respective status tags", () => {
|
||||
renderWithProviders(
|
||||
<Item
|
||||
groupName="G"
|
||||
useEqualHeights={false}
|
||||
service={{
|
||||
id: "svc1",
|
||||
name: "My Service",
|
||||
description: "Desc",
|
||||
app: "app",
|
||||
namespace: "default",
|
||||
proxmoxNode: "pve",
|
||||
proxmoxVMID: "100",
|
||||
proxmoxType: "qemu",
|
||||
widgets: [],
|
||||
}}
|
||||
/>,
|
||||
{ settings: { showStats: false, statusStyle: "basic" } },
|
||||
);
|
||||
|
||||
const appBtn = screen.getByTestId("kubernetes-status").closest("button");
|
||||
expect(appBtn).toBeTruthy();
|
||||
fireEvent.click(appBtn);
|
||||
expect(screen.getByTestId("kubernetes-widget")).toBeInTheDocument();
|
||||
|
||||
const proxmoxBtn = screen.getByTestId("proxmox-status").closest("button");
|
||||
expect(proxmoxBtn).toBeTruthy();
|
||||
fireEvent.click(proxmoxBtn);
|
||||
expect(screen.getByTestId("proxmoxvm-widget")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the app status tag when the service is marked external", () => {
|
||||
renderWithProviders(
|
||||
<Item
|
||||
groupName="G"
|
||||
useEqualHeights={false}
|
||||
service={{
|
||||
id: "svc1",
|
||||
name: "My Service",
|
||||
description: "Desc",
|
||||
app: "app",
|
||||
external: true,
|
||||
widgets: [],
|
||||
}}
|
||||
/>,
|
||||
{ settings: { showStats: false, statusStyle: "basic" } },
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("kubernetes-status")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
65
src/components/services/kubernetes-status.test.jsx
Normal file
65
src/components/services/kubernetes-status.test.jsx
Normal file
@ -0,0 +1,65 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: useSWR,
|
||||
}));
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key) => key,
|
||||
}));
|
||||
|
||||
import KubernetesStatus from "./kubernetes-status";
|
||||
|
||||
describe("components/services/kubernetes-status", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("includes podSelector in the request when provided", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
render(<KubernetesStatus service={{ namespace: "ns", app: "app", podSelector: "x=y" }} />);
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith("/api/kubernetes/status/ns/app?podSelector=x=y");
|
||||
});
|
||||
|
||||
it("renders the health/status label when running", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running", health: "healthy" }, error: undefined });
|
||||
|
||||
render(<KubernetesStatus service={{ namespace: "ns", app: "app" }} />);
|
||||
|
||||
expect(screen.getByText("healthy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a dot when style is dot", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running" }, error: undefined });
|
||||
|
||||
const { container } = render(<KubernetesStatus service={{ namespace: "ns", app: "app" }} style="dot" />);
|
||||
|
||||
expect(container.querySelector(".rounded-full")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders an error label when SWR returns an error", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
const { container } = render(<KubernetesStatus service={{ namespace: "ns", app: "app" }} />);
|
||||
|
||||
expect(screen.getByText("docker.error")).toBeInTheDocument();
|
||||
expect(container.querySelector(".k8s-status")?.getAttribute("title")).toBe("docker.error");
|
||||
});
|
||||
|
||||
it("renders orange status labels when the workload is down/partial/not found", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "down" }, error: undefined });
|
||||
|
||||
const { container } = render(<KubernetesStatus service={{ namespace: "ns", app: "app" }} />);
|
||||
|
||||
expect(screen.getByText("down")).toBeInTheDocument();
|
||||
// Ensure the status is used as a tooltip/title too.
|
||||
expect(container.querySelector(".k8s-status")?.getAttribute("title")).toBe("down");
|
||||
});
|
||||
});
|
||||
35
src/components/services/list.test.jsx
Normal file
35
src/components/services/list.test.jsx
Normal file
@ -0,0 +1,35 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("components/services/item", () => ({
|
||||
default: function ServiceItemMock({ service, groupName, useEqualHeights }) {
|
||||
return (
|
||||
<li data-testid="service-item">
|
||||
{groupName}:{service.name}:{String(useEqualHeights)}
|
||||
</li>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import List from "./list";
|
||||
|
||||
describe("components/services/list", () => {
|
||||
it("renders items and passes the computed useEqualHeights value", () => {
|
||||
render(
|
||||
<List
|
||||
groupName="G"
|
||||
services={[{ name: "A" }, { name: "B" }]}
|
||||
layout={{ useEqualHeights: true }}
|
||||
useEqualHeights={false}
|
||||
header
|
||||
/>,
|
||||
);
|
||||
|
||||
const items = screen.getAllByTestId("service-item");
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toHaveTextContent("G:A:true");
|
||||
expect(items[1]).toHaveTextContent("G:B:true");
|
||||
});
|
||||
});
|
||||
76
src/components/services/ping.test.jsx
Normal file
76
src/components/services/ping.test.jsx
Normal file
@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: useSWR,
|
||||
}));
|
||||
|
||||
import Ping from "./ping";
|
||||
|
||||
describe("components/services/ping", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a loading state when data is not available yet", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
render(<Ping groupName="g" serviceName="s" />);
|
||||
|
||||
expect(screen.getByText("ping.ping")).toBeInTheDocument();
|
||||
expect(screen.getByText("ping.ping").closest(".ping-status")).toHaveAttribute(
|
||||
"title",
|
||||
expect.stringContaining("ping.not_available"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders an error label when SWR returns error", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("boom") });
|
||||
|
||||
render(<Ping groupName="g" serviceName="s" />);
|
||||
|
||||
expect(screen.getByText("ping.error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders down when the host is not alive", () => {
|
||||
useSWR.mockReturnValue({ data: { alive: false, time: 0 }, error: undefined });
|
||||
|
||||
render(<Ping groupName="g" serviceName="s" />);
|
||||
|
||||
expect(screen.getByText("ping.down")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the ping time when the host is alive", () => {
|
||||
useSWR.mockReturnValue({ data: { alive: true, time: 123 }, error: undefined });
|
||||
|
||||
render(<Ping groupName="g" serviceName="s" />);
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith("/api/ping?groupName=g&serviceName=s", { refreshInterval: 30000 });
|
||||
expect(screen.getByText("123")).toBeInTheDocument();
|
||||
expect(screen.getByText("123").closest(".ping-status")).toHaveAttribute(
|
||||
"title",
|
||||
expect.stringContaining("ping.up"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders an up label for basic style", () => {
|
||||
useSWR.mockReturnValue({ data: { alive: true, time: 1 }, error: undefined });
|
||||
|
||||
render(<Ping groupName="g" serviceName="s" style="basic" />);
|
||||
|
||||
expect(screen.getByText("ping.up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a dot when style is dot", () => {
|
||||
useSWR.mockReturnValue({ data: { alive: true, time: 5 }, error: undefined });
|
||||
|
||||
const { container } = render(<Ping groupName="g" serviceName="s" style="dot" />);
|
||||
|
||||
expect(screen.queryByText("5")).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".rounded-full")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
75
src/components/services/proxmox-status.test.jsx
Normal file
75
src/components/services/proxmox-status.test.jsx
Normal file
@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: useSWR,
|
||||
}));
|
||||
|
||||
import ProxmoxStatus from "./proxmox-status";
|
||||
|
||||
describe("components/services/proxmox-status", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders unknown when data is not available yet", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100" }} />);
|
||||
|
||||
expect(screen.getByText("docker.unknown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error when SWR returns an error", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100" }} />);
|
||||
|
||||
expect(screen.getByText("docker.error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("requests vm stats and renders running when status is running", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running" }, error: undefined });
|
||||
|
||||
render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100" }} />);
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith("/api/proxmox/stats/n1/100?type=qemu");
|
||||
expect(screen.getByText("docker.running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders paused for paused vms", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "paused" }, error: undefined });
|
||||
|
||||
render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100", proxmoxType: "lxc" }} />);
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith("/api/proxmox/stats/n1/100?type=lxc");
|
||||
expect(screen.getByText("paused")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders other terminal statuses (stopped/offline/not found)", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "stopped" }, error: undefined });
|
||||
render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100" }} />);
|
||||
expect(screen.getByText("docker.exited")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "offline" }, error: undefined });
|
||||
render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100" }} />);
|
||||
expect(screen.getByText("offline")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "not found" }, error: undefined });
|
||||
render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100" }} />);
|
||||
expect(screen.getByText("docker.not_found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a dot status when style=dot", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running" }, error: undefined });
|
||||
|
||||
const { container } = render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100" }} style="dot" />);
|
||||
|
||||
expect(container.querySelector(".rounded-full")).toBeTruthy();
|
||||
expect(screen.queryByText("docker.running")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
88
src/components/services/site-monitor.test.jsx
Normal file
88
src/components/services/site-monitor.test.jsx
Normal file
@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: useSWR,
|
||||
}));
|
||||
|
||||
import SiteMonitor from "./site-monitor";
|
||||
|
||||
describe("components/services/site-monitor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a loading state when data is not available yet", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
render(<SiteMonitor groupName="g" serviceName="s" />);
|
||||
|
||||
expect(screen.getByText("siteMonitor.response")).toBeInTheDocument();
|
||||
expect(screen.getByText("siteMonitor.response").closest(".site-monitor-status")).toHaveAttribute(
|
||||
"title",
|
||||
expect.stringContaining("siteMonitor.not_available"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders response time when status is up", () => {
|
||||
useSWR.mockReturnValue({ data: { status: 200, latency: 10 }, error: undefined });
|
||||
|
||||
render(<SiteMonitor groupName="g" serviceName="s" />);
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith("/api/siteMonitor?groupName=g&serviceName=s", { refreshInterval: 30000 });
|
||||
expect(screen.getByText("10")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders up label for basic style when status is ok", () => {
|
||||
useSWR.mockReturnValue({ data: { status: 200, latency: 1 }, error: undefined });
|
||||
|
||||
render(<SiteMonitor groupName="g" serviceName="s" style="basic" />);
|
||||
|
||||
expect(screen.getByText("siteMonitor.up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders down label for failing status in basic style", () => {
|
||||
useSWR.mockReturnValue({ data: { status: 500, latency: 0 }, error: undefined });
|
||||
|
||||
render(<SiteMonitor groupName="g" serviceName="s" style="basic" />);
|
||||
|
||||
expect(screen.getByText("siteMonitor.down")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the http status code for failing status in non-basic style", () => {
|
||||
useSWR.mockReturnValue({ data: { status: 500, latency: 0 }, error: undefined });
|
||||
|
||||
render(<SiteMonitor groupName="g" serviceName="s" />);
|
||||
|
||||
expect(screen.getByText("500")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an error label when SWR returns error", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("boom") });
|
||||
|
||||
render(<SiteMonitor groupName="g" serviceName="s" />);
|
||||
|
||||
expect(screen.getByText("siteMonitor.error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats an embedded data.error as an error state", () => {
|
||||
useSWR.mockReturnValue({ data: { error: "bad" }, error: undefined });
|
||||
|
||||
render(<SiteMonitor groupName="g" serviceName="s" />);
|
||||
|
||||
expect(screen.getByText("siteMonitor.error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a dot when style is dot", () => {
|
||||
useSWR.mockReturnValue({ data: { status: 500, latency: 0 }, error: undefined });
|
||||
|
||||
const { container } = render(<SiteMonitor groupName="g" serviceName="s" style="dot" />);
|
||||
|
||||
expect(container.querySelector(".rounded-full")).toBeTruthy();
|
||||
expect(screen.queryByText("500")).toBeNull();
|
||||
});
|
||||
});
|
||||
74
src/components/services/status.test.jsx
Normal file
74
src/components/services/status.test.jsx
Normal file
@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: useSWR,
|
||||
}));
|
||||
|
||||
import Status from "./status";
|
||||
|
||||
describe("components/services/status", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("requests docker status and renders unknown by default", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith("/api/docker/status/c/s");
|
||||
expect(screen.getByText("docker.unknown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error when SWR fails", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
|
||||
expect(screen.getByText("docker.error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders healthy/unhealthy and partial/exited/not found statuses", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running", health: "healthy" }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText("docker.healthy")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "running", health: "unhealthy" }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText("docker.unhealthy")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "partial 1/2" }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText("docker.partial 1/2")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "exited" }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText("docker.exited")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "not found" }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText("docker.not_found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders starting health when container is running and starting", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running", health: "starting" }, error: undefined });
|
||||
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
|
||||
expect(screen.getByText("docker.starting")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a dot when style is dot", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running" }, error: undefined });
|
||||
|
||||
const { container } = render(<Status service={{ container: "c", server: "s" }} style="dot" />);
|
||||
|
||||
expect(screen.queryByText("docker.running")).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".rounded-full")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -8,8 +8,7 @@ export default function Widget({ widget, service }) {
|
||||
|
||||
const ServiceWidget = components[widget.type];
|
||||
|
||||
const fullService = Object.apply({}, service);
|
||||
fullService.widget = widget;
|
||||
const fullService = { ...service, widget };
|
||||
if (ServiceWidget) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
|
||||
38
src/components/services/widget.test.jsx
Normal file
38
src/components/services/widget.test.jsx
Normal file
@ -0,0 +1,38 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("components/errorboundry", () => ({
|
||||
default: function ErrorBoundaryMock({ children }) {
|
||||
return <>{children}</>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("widgets/components", () => ({
|
||||
default: {
|
||||
mock: function MockWidget({ service }) {
|
||||
return (
|
||||
<div data-testid="mock-service-widget">
|
||||
{service.name}:{service.widget?.type}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import Widget from "./widget";
|
||||
|
||||
describe("components/services/widget", () => {
|
||||
it("renders the mapped widget component and passes merged service.widget", () => {
|
||||
render(<Widget widget={{ type: "mock" }} service={{ name: "Svc" }} />);
|
||||
|
||||
expect(screen.getByTestId("mock-service-widget")).toHaveTextContent("Svc:mock");
|
||||
});
|
||||
|
||||
it("renders a missing widget message when the type is unknown", () => {
|
||||
render(<Widget widget={{ type: "nope" }} service={{ name: "Svc" }} />);
|
||||
|
||||
expect(screen.getByText("widget.missing_type")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
41
src/components/services/widget/block.test.jsx
Normal file
41
src/components/services/widget/block.test.jsx
Normal file
@ -0,0 +1,41 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import Block from "./block";
|
||||
import { BlockHighlightContext } from "./highlight-context";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
describe("components/services/widget/block", () => {
|
||||
it("renders a placeholder when value is undefined", () => {
|
||||
const { container } = renderWithProviders(<Block label="some.label" />, { settings: {} });
|
||||
|
||||
// Value slot is rendered as "-" while loading.
|
||||
expect(container.textContent).toContain("-");
|
||||
expect(container.textContent).toContain("some.label");
|
||||
});
|
||||
|
||||
it("sets highlight metadata when a rule matches", () => {
|
||||
const highlightConfig = {
|
||||
levels: { danger: "danger-class" },
|
||||
fields: {
|
||||
foo: {
|
||||
numeric: { when: "gt", value: 10, level: "danger" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderWithProviders(
|
||||
<BlockHighlightContext.Provider value={highlightConfig}>
|
||||
<Block label="foo.label" field="foo" value="11" />
|
||||
</BlockHighlightContext.Provider>,
|
||||
{ settings: {} },
|
||||
);
|
||||
|
||||
const el = container.querySelector(".service-block");
|
||||
expect(el).not.toBeNull();
|
||||
expect(el.getAttribute("data-highlight-level")).toBe("danger");
|
||||
expect(el.className).toContain("danger-class");
|
||||
});
|
||||
});
|
||||
86
src/components/services/widget/container.test.jsx
Normal file
86
src/components/services/widget/container.test.jsx
Normal file
@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { useContext } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
import Container from "./container";
|
||||
import { BlockHighlightContext } from "./highlight-context";
|
||||
|
||||
function Dummy({ label }) {
|
||||
return <div data-testid={label} />;
|
||||
}
|
||||
|
||||
function HighlightProbe() {
|
||||
const value = useContext(BlockHighlightContext);
|
||||
return <div data-testid="highlight-probe" data-highlight={value ? "yes" : "no"} />;
|
||||
}
|
||||
|
||||
describe("components/services/widget/container", () => {
|
||||
it("filters children based on widget.fields (auto-namespaced by widget type)", () => {
|
||||
renderWithProviders(
|
||||
<Container service={{ widget: { type: "omada", fields: ["connectedAp", "alerts"] } }}>
|
||||
<Dummy label="omada.connectedAp" />
|
||||
<Dummy label="omada.alerts" />
|
||||
<Dummy label="omada.activeUser" />
|
||||
</Container>,
|
||||
{ settings: {} },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("omada.connectedAp")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("omada.alerts")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("omada.activeUser")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts widget.fields as a JSON string", () => {
|
||||
renderWithProviders(
|
||||
<Container service={{ widget: { type: "omada", fields: JSON.stringify(["alerts"]) } }}>
|
||||
<Dummy label="omada.connectedAp" />
|
||||
<Dummy label="omada.alerts" />
|
||||
</Container>,
|
||||
{ settings: {} },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("omada.alerts")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("omada.connectedAp")).toBeNull();
|
||||
});
|
||||
|
||||
it("supports aliased widget types when filtering (hoarder -> karakeep)", () => {
|
||||
renderWithProviders(
|
||||
<Container service={{ widget: { type: "hoarder", fields: ["hoarder.count"] } }}>
|
||||
<Dummy label="karakeep.count" />
|
||||
</Container>,
|
||||
{ settings: {} },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("karakeep.count")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns null when errors are hidden via settings.hideErrors", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<Container error="nope" service={{ widget: { type: "omada", hide_errors: false } }}>
|
||||
<Dummy label="omada.alerts" />
|
||||
</Container>,
|
||||
{ settings: { hideErrors: true } },
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("skips the highlight provider when highlight levels are fully disabled", () => {
|
||||
renderWithProviders(
|
||||
<Container service={{ widget: { type: "omada" } }}>
|
||||
<HighlightProbe />
|
||||
</Container>,
|
||||
{
|
||||
settings: {
|
||||
blockHighlights: { levels: { good: null, warn: null, danger: null } },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("highlight-probe").getAttribute("data-highlight")).toBe("no");
|
||||
});
|
||||
});
|
||||
45
src/components/services/widget/error.test.jsx
Normal file
45
src/components/services/widget/error.test.jsx
Normal file
@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import Error from "./error";
|
||||
|
||||
describe("components/services/widget/error", () => {
|
||||
it("normalizes string errors to an object with a message", () => {
|
||||
render(<Error error="boom" />);
|
||||
|
||||
expect(screen.getByText((_, el) => el?.textContent === "widget.api_error:")).toBeInTheDocument();
|
||||
expect(screen.getByText(/boom/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("normalizes numeric errors to an object with a message", () => {
|
||||
render(<Error error={500} />);
|
||||
|
||||
expect(screen.getByText(/Error 500/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("unwraps nested response errors and renders raw/data sections", () => {
|
||||
render(
|
||||
<Error
|
||||
error={{
|
||||
message: "outer",
|
||||
data: {
|
||||
error: {
|
||||
message: "inner",
|
||||
url: "https://example.com",
|
||||
rawError: ["oops", { code: 1 }],
|
||||
data: { type: "Buffer", data: [97, 98] },
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/inner/)).toBeInTheDocument();
|
||||
expect(screen.getByText("https://example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText(/\"code\": 1/)).toBeInTheDocument();
|
||||
// Buffer.from({type:"Buffer",data:[97,98]}).toString() === "ab"
|
||||
expect(screen.getByText(/ab/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
29
src/components/services/widget/highlight-context.test.jsx
Normal file
29
src/components/services/widget/highlight-context.test.jsx
Normal file
@ -0,0 +1,29 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { useContext } from "react";
|
||||
|
||||
import { BlockHighlightContext } from "./highlight-context";
|
||||
|
||||
function Reader() {
|
||||
const value = useContext(BlockHighlightContext);
|
||||
return <div data-testid="value">{value === null ? "null" : value}</div>;
|
||||
}
|
||||
|
||||
describe("components/services/widget/highlight-context", () => {
|
||||
it("defaults to null", () => {
|
||||
render(<Reader />);
|
||||
expect(screen.getByTestId("value")).toHaveTextContent("null");
|
||||
});
|
||||
|
||||
it("provides a value to consumers", () => {
|
||||
render(
|
||||
<BlockHighlightContext.Provider value="on">
|
||||
<Reader />
|
||||
</BlockHighlightContext.Provider>,
|
||||
);
|
||||
expect(screen.getByTestId("value")).toHaveTextContent("on");
|
||||
});
|
||||
});
|
||||
32
src/components/tab.test.jsx
Normal file
32
src/components/tab.test.jsx
Normal file
@ -0,0 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TabContext } from "utils/contexts/tab";
|
||||
|
||||
import Tab, { slugifyAndEncode } from "./tab";
|
||||
|
||||
describe("components/tab", () => {
|
||||
it("slugifyAndEncode lowercases and encodes spaces", () => {
|
||||
expect(slugifyAndEncode("My Tab")).toBe("my-tab");
|
||||
expect(slugifyAndEncode(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("marks the matching tab as selected and updates hash on click", () => {
|
||||
const setActiveTab = vi.fn();
|
||||
|
||||
render(
|
||||
<TabContext.Provider value={{ activeTab: "my-tab", setActiveTab }}>
|
||||
<Tab tab="My Tab" />
|
||||
</TabContext.Provider>,
|
||||
);
|
||||
|
||||
const btn = screen.getByRole("tab");
|
||||
expect(btn.getAttribute("aria-selected")).toBe("true");
|
||||
|
||||
fireEvent.click(btn);
|
||||
expect(setActiveTab).toHaveBeenCalledWith("my-tab");
|
||||
expect(window.location.hash).toBe("#my-tab");
|
||||
});
|
||||
});
|
||||
59
src/components/toggles/color.test.jsx
Normal file
59
src/components/toggles/color.test.jsx
Normal file
@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ColorContext } from "utils/contexts/color";
|
||||
|
||||
// Stub Popover/Transition to always render children.
|
||||
vi.mock("@headlessui/react", async () => {
|
||||
const React = await import("react");
|
||||
const { Fragment } = React;
|
||||
|
||||
function passthrough({ as: As = "div", children, ...props }) {
|
||||
if (As === Fragment) return <>{typeof children === "function" ? children({ open: true }) : children}</>;
|
||||
const content = typeof children === "function" ? children({ open: true }) : children;
|
||||
return <As {...props}>{content}</As>;
|
||||
}
|
||||
|
||||
function Popover({ children }) {
|
||||
return <div>{typeof children === "function" ? children({ open: true }) : children}</div>;
|
||||
}
|
||||
function PopoverButton(props) {
|
||||
return <button type="button" {...props} />;
|
||||
}
|
||||
function PopoverPanel(props) {
|
||||
return <div {...props} />;
|
||||
}
|
||||
Popover.Button = PopoverButton;
|
||||
Popover.Panel = PopoverPanel;
|
||||
|
||||
return { Popover, Transition: passthrough };
|
||||
});
|
||||
|
||||
import ColorToggle from "./color";
|
||||
|
||||
describe("components/toggles/color", () => {
|
||||
it("renders nothing when no active color is set", () => {
|
||||
const { container } = render(
|
||||
<ColorContext.Provider value={{ color: null, setColor: vi.fn() }}>
|
||||
<ColorToggle />
|
||||
</ColorContext.Provider>,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("invokes setColor when a color button is clicked", () => {
|
||||
const setColor = vi.fn();
|
||||
render(
|
||||
<ColorContext.Provider value={{ color: "slate", setColor }}>
|
||||
<ColorToggle />
|
||||
</ColorContext.Provider>,
|
||||
);
|
||||
|
||||
// Buttons contain a sr-only span with the color name.
|
||||
const blue = screen.getByText("blue").closest("button");
|
||||
fireEvent.click(blue);
|
||||
expect(setColor).toHaveBeenCalledWith("blue");
|
||||
});
|
||||
});
|
||||
27
src/components/toggles/revalidate.test.jsx
Normal file
27
src/components/toggles/revalidate.test.jsx
Normal file
@ -0,0 +1,27 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import Revalidate from "./revalidate";
|
||||
|
||||
describe("components/toggles/revalidate", () => {
|
||||
it("calls /api/revalidate and reloads when ok", async () => {
|
||||
const reload = vi.fn();
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal("location", { reload });
|
||||
|
||||
render(<Revalidate />);
|
||||
const icon = document.querySelector("svg");
|
||||
fireEvent.click(icon);
|
||||
|
||||
// allow promise chain to flush
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith("/api/revalidate");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
46
src/components/toggles/theme.test.jsx
Normal file
46
src/components/toggles/theme.test.jsx
Normal file
@ -0,0 +1,46 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThemeContext } from "utils/contexts/theme";
|
||||
|
||||
import ThemeToggle from "./theme";
|
||||
|
||||
describe("components/toggles/theme", () => {
|
||||
it("renders nothing when theme is missing", () => {
|
||||
const { container } = render(
|
||||
<ThemeContext.Provider value={{ theme: null, setTheme: vi.fn() }}>
|
||||
<ThemeToggle />
|
||||
</ThemeContext.Provider>,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("toggles from dark to light when clicked", () => {
|
||||
const setTheme = vi.fn();
|
||||
render(
|
||||
<ThemeContext.Provider value={{ theme: "dark", setTheme }}>
|
||||
<ThemeToggle />
|
||||
</ThemeContext.Provider>,
|
||||
);
|
||||
|
||||
// The toggle is a clickable icon rendered as an svg (react-icons).
|
||||
const toggles = document.querySelectorAll("svg");
|
||||
fireEvent.click(toggles[1]);
|
||||
expect(setTheme).toHaveBeenCalledWith("light");
|
||||
});
|
||||
|
||||
it("toggles from light to dark when clicked", () => {
|
||||
const setTheme = vi.fn();
|
||||
render(
|
||||
<ThemeContext.Provider value={{ theme: "light", setTheme }}>
|
||||
<ThemeToggle />
|
||||
</ThemeContext.Provider>,
|
||||
);
|
||||
|
||||
const toggles = document.querySelectorAll("svg");
|
||||
fireEvent.click(toggles[1]);
|
||||
expect(setTheme).toHaveBeenCalledWith("dark");
|
||||
});
|
||||
});
|
||||
85
src/components/version.test.jsx
Normal file
85
src/components/version.test.jsx
Normal file
@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { cache, cv, useSWR } = vi.hoisted(() => ({
|
||||
cache: {
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
},
|
||||
cv: {
|
||||
validate: vi.fn(),
|
||||
compareVersions: vi.fn(),
|
||||
},
|
||||
useSWR: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("memory-cache", () => ({
|
||||
default: cache,
|
||||
}));
|
||||
|
||||
vi.mock("compare-versions", () => ({
|
||||
validate: cv.validate,
|
||||
compareVersions: cv.compareVersions,
|
||||
}));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: useSWR,
|
||||
}));
|
||||
|
||||
import Version from "./version";
|
||||
|
||||
describe("components/version", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.NEXT_PUBLIC_VERSION = "dev";
|
||||
process.env.NEXT_PUBLIC_REVISION = "abcdef012345";
|
||||
process.env.NEXT_PUBLIC_BUILDTIME = "2020-01-01T00:00:00.000Z";
|
||||
});
|
||||
|
||||
it("renders non-link version text for dev/main/nightly", () => {
|
||||
cv.validate.mockReturnValue(false);
|
||||
cache.get.mockReturnValue(null);
|
||||
useSWR.mockReturnValue({ data: undefined });
|
||||
|
||||
render(<Version />);
|
||||
|
||||
expect(screen.getByText(/dev \(abcdef0/)).toBeInTheDocument();
|
||||
expect(screen.queryAllByRole("link")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders tag link and shows update available when a newer release exists", () => {
|
||||
process.env.NEXT_PUBLIC_VERSION = "1.2.3";
|
||||
cv.validate.mockReturnValue(true);
|
||||
cache.get.mockReturnValue(null);
|
||||
useSWR.mockReturnValue({
|
||||
data: [{ tag_name: "1.2.4", html_url: "http://example.com/release" }],
|
||||
});
|
||||
cv.compareVersions.mockReturnValue(1);
|
||||
|
||||
render(<Version />);
|
||||
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links.find((a) => a.getAttribute("href")?.includes("/releases/tag/1.2.3"))).toBeTruthy();
|
||||
expect(links.find((a) => a.getAttribute("href") === "http://example.com/release")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back build time to the current date when NEXT_PUBLIC_BUILDTIME is missing", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2021-01-02T12:00:00.000Z"));
|
||||
process.env.NEXT_PUBLIC_BUILDTIME = "";
|
||||
|
||||
cv.validate.mockReturnValue(false);
|
||||
cache.get.mockReturnValue(null);
|
||||
useSWR.mockReturnValue({ data: undefined });
|
||||
|
||||
render(<Version />);
|
||||
|
||||
expect(screen.getByText(/2021/)).toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
32
src/components/widgets/datetime/datetime.test.jsx
Normal file
32
src/components/widgets/datetime/datetime.test.jsx
Normal file
@ -0,0 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
import DateTime from "./datetime";
|
||||
|
||||
describe("components/widgets/datetime", () => {
|
||||
it("renders formatted date/time and updates on an interval", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2020-01-01T00:00:00.000Z"));
|
||||
|
||||
const format = { timeZone: "UTC", hour: "2-digit", minute: "2-digit", second: "2-digit" };
|
||||
const expected0 = new Intl.DateTimeFormat("en-US", format).format(new Date());
|
||||
|
||||
renderWithProviders(<DateTime options={{ locale: "en-US", format }} />, { settings: { target: "_self" } });
|
||||
|
||||
// `render` wraps in `act`, so effects should flush synchronously.
|
||||
expect(screen.getByText(expected0)).toBeInTheDocument();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
const expected1 = new Intl.DateTimeFormat("en-US", format).format(new Date());
|
||||
|
||||
expect(screen.getByText(expected1)).toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
166
src/components/widgets/glances/glances.test.jsx
Normal file
166
src/components/widgets/glances/glances.test.jsx
Normal file
@ -0,0 +1,166 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
vi.mock("swr", () => ({ default: useSWR }));
|
||||
|
||||
import Glances from "./glances";
|
||||
|
||||
describe("components/widgets/glances", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders an error state when SWR errors", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
renderWithProviders(<Glances options={{ cpu: true, mem: true }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders placeholder resources while loading", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<Glances options={{ cpu: true, mem: true, cputemp: true, disk: "/", uptime: true }} />, {
|
||||
settings: { target: "_self" },
|
||||
});
|
||||
|
||||
// All placeholders use glances.wait.
|
||||
expect(screen.getAllByText("glances.wait").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders placeholder disk resources when loading and disk is an array", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<Glances options={{ disk: ["/", "/data"] }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getAllByText("glances.wait").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders cpu percent and memory available when data is present", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
cpu: { total: 12.34 },
|
||||
load: { min15: 5 },
|
||||
mem: { available: 1024, total: 2048, percent: 50 },
|
||||
fs: [{ mnt_point: "/", free: 100, size: 200, percent: 50 }],
|
||||
sensors: [],
|
||||
uptime: "1 days, 00:00:00",
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Glances options={{ cpu: true, mem: true, disk: "/", uptime: true }} />, {
|
||||
settings: { target: "_self" },
|
||||
});
|
||||
|
||||
// common.number is mocked to return the numeric value as a string.
|
||||
expect(screen.getByText("12.34")).toBeInTheDocument();
|
||||
// common.bytes is mocked similarly; we just assert the numeric value is present.
|
||||
expect(screen.getByText("1024")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles cpu sensor retrieval failures gracefully", () => {
|
||||
const sensor = {
|
||||
label: "cpu_thermal-0",
|
||||
type: "temperature_core",
|
||||
get value() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
warning: 90,
|
||||
};
|
||||
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
cpu: { total: 1 },
|
||||
load: { min15: 1 },
|
||||
mem: { available: 1, total: 1, percent: 1 },
|
||||
fs: [],
|
||||
sensors: [sensor],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Glances options={{ cputemp: true }} />, { settings: { target: "_self" } });
|
||||
|
||||
// When sensor processing fails, it should not render the temp block.
|
||||
expect(screen.queryByText("glances.temp")).toBeNull();
|
||||
expect(screen.getByText("glances.cpu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders temperature in fahrenheit for matching cpu sensors and marks the widget expanded", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
cpu: { total: 1 },
|
||||
load: { min15: 1 },
|
||||
mem: { available: 1, total: 1, percent: 1 },
|
||||
fs: [],
|
||||
sensors: [
|
||||
{ label: "cpu_thermal-0", type: "temperature_core", value: 40, warning: 90 },
|
||||
{ label: "Core 1", type: "temperature_core", value: 50, warning: 100 },
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<Glances options={{ cputemp: true, units: "imperial", expanded: true, url: "http://glances" }} />,
|
||||
{
|
||||
settings: { target: "_self" },
|
||||
},
|
||||
);
|
||||
|
||||
// avg(40,50)=45C => 113F
|
||||
expect(screen.getByText("113")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link")).toHaveClass("expanded");
|
||||
});
|
||||
|
||||
it("renders disk resources for an array of mount points and filters missing mounts", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
cpu: { total: 1 },
|
||||
load: { min15: 1 },
|
||||
mem: { available: 1, total: 1, percent: 1 },
|
||||
fs: [{ mnt_point: "/", free: 10, size: 20, percent: 50 }],
|
||||
sensors: [],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<Glances options={{ disk: ["/", "/missing"], diskUnits: "bbytes", expanded: true, url: "http://glances" }} />,
|
||||
{
|
||||
settings: { target: "_self" },
|
||||
},
|
||||
);
|
||||
|
||||
// only one mount exists, but both free + total values should render for it
|
||||
expect(screen.getByText("10")).toBeInTheDocument();
|
||||
expect(screen.getByText("20")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("formats uptime into translated day/hour labels", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
cpu: { total: 1 },
|
||||
load: { min15: 1 },
|
||||
mem: { available: 1, total: 1, percent: 1 },
|
||||
fs: [],
|
||||
sensors: [],
|
||||
uptime: "1 days, 00:00:00",
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Glances options={{ uptime: true, url: "http://glances" }} />, {
|
||||
settings: { target: "_self" },
|
||||
});
|
||||
|
||||
expect(screen.getByText("1glances.days 00glances.hours")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
20
src/components/widgets/greeting/greeting.test.jsx
Normal file
20
src/components/widgets/greeting/greeting.test.jsx
Normal file
@ -0,0 +1,20 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
import Greeting from "./greeting";
|
||||
|
||||
describe("components/widgets/greeting", () => {
|
||||
it("renders nothing when text is not configured", () => {
|
||||
const { container } = renderWithProviders(<Greeting options={{}} />, { settings: { target: "_self" } });
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders configured greeting text", () => {
|
||||
renderWithProviders(<Greeting options={{ text: "Hello there" }} />, { settings: { target: "_self" } });
|
||||
expect(screen.getByText("Hello there")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
55
src/components/widgets/kubernetes/kubernetes.test.jsx
Normal file
55
src/components/widgets/kubernetes/kubernetes.test.jsx
Normal file
@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
vi.mock("swr", () => ({ default: useSWR }));
|
||||
|
||||
vi.mock("./node", () => ({
|
||||
default: ({ type }) => <div data-testid="kube-node" data-type={type} />,
|
||||
}));
|
||||
|
||||
import Kubernetes from "./kubernetes";
|
||||
|
||||
describe("components/widgets/kubernetes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders an error state when SWR errors", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
renderWithProviders(<Kubernetes options={{ cluster: { show: true }, nodes: { show: true } }} />, {
|
||||
settings: { target: "_self" },
|
||||
});
|
||||
|
||||
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders placeholder nodes while loading", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<Kubernetes options={{ cluster: { show: true }, nodes: { show: true } }} />, {
|
||||
settings: { target: "_self" },
|
||||
});
|
||||
|
||||
expect(screen.getAllByTestId("kube-node").map((n) => n.getAttribute("data-type"))).toEqual(["cluster", "node"]);
|
||||
});
|
||||
|
||||
it("renders a node per returned entry when data is available", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: { cluster: {}, nodes: [{ name: "n1" }, { name: "n2" }] },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Kubernetes options={{ cluster: { show: true }, nodes: { show: true } }} />, {
|
||||
settings: { target: "_self" },
|
||||
});
|
||||
|
||||
// cluster + 2 nodes
|
||||
expect(screen.getAllByTestId("kube-node")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
35
src/components/widgets/kubernetes/node.test.jsx
Normal file
35
src/components/widgets/kubernetes/node.test.jsx
Normal file
@ -0,0 +1,35 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import Node from "./node";
|
||||
|
||||
describe("components/widgets/kubernetes/node", () => {
|
||||
it("renders cluster label when showLabel is enabled", () => {
|
||||
const data = { cpu: { percent: 50 }, memory: { free: 123, percent: 10 } };
|
||||
|
||||
const { container } = render(<Node type="cluster" options={{ showLabel: true, label: "Cluster A" }} data={data} />);
|
||||
|
||||
expect(screen.getByText("50")).toBeInTheDocument();
|
||||
expect(screen.getByText("123")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cluster A")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('div[style*="width:"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders node name when showLabel is enabled for node type", () => {
|
||||
const data = { name: "node-1", ready: true, cpu: { percent: 1 }, memory: { free: 2, percent: 3 } };
|
||||
|
||||
render(<Node type="node" options={{ showLabel: true }} data={data} />);
|
||||
|
||||
expect(screen.getByText("node-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a warning icon when the node is not ready", () => {
|
||||
const data = { name: "node-2", ready: false, cpu: { percent: 1 }, memory: { free: 2, percent: 3 } };
|
||||
|
||||
render(<Node type="node" options={{ showLabel: true }} data={data} />);
|
||||
|
||||
expect(screen.getByText("node-2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
26
src/components/widgets/logo/logo.test.jsx
Normal file
26
src/components/widgets/logo/logo.test.jsx
Normal file
@ -0,0 +1,26 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
vi.mock("components/resolvedicon", () => ({
|
||||
default: ({ icon }) => <div data-testid="resolved-icon" data-icon={icon} />,
|
||||
}));
|
||||
|
||||
import Logo from "./logo";
|
||||
|
||||
describe("components/widgets/logo", () => {
|
||||
it("renders a fallback SVG when no icon is configured", () => {
|
||||
const { container } = renderWithProviders(<Logo options={{}} />, { settings: { target: "_self" } });
|
||||
expect(screen.queryByTestId("resolved-icon")).toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the configured icon via ResolvedIcon", () => {
|
||||
renderWithProviders(<Logo options={{ icon: "mdi:home" }} />, { settings: { target: "_self" } });
|
||||
const icon = screen.getByTestId("resolved-icon");
|
||||
expect(icon.getAttribute("data-icon")).toBe("mdi:home");
|
||||
});
|
||||
});
|
||||
72
src/components/widgets/longhorn/longhorn.test.jsx
Normal file
72
src/components/widgets/longhorn/longhorn.test.jsx
Normal file
@ -0,0 +1,72 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
|
||||
vi.mock("swr", () => ({ default: useSWR }));
|
||||
|
||||
vi.mock("./node", () => ({
|
||||
default: ({ data }) => <div data-testid="longhorn-node" data-id={data.node.id} />,
|
||||
}));
|
||||
|
||||
import Longhorn from "./longhorn";
|
||||
|
||||
describe("components/widgets/longhorn", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders an error state when SWR errors", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
renderWithProviders(<Longhorn options={{ nodes: true, total: true }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an empty container while loading", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
const { container } = renderWithProviders(<Longhorn options={{ nodes: true, total: true }} />, {
|
||||
settings: { target: "_self" },
|
||||
});
|
||||
|
||||
expect(container.querySelector(".infomation-widget-longhorn")).not.toBeNull();
|
||||
expect(screen.queryAllByTestId("longhorn-node")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("filters nodes based on options (total/include)", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
nodes: [{ id: "total" }, { id: "node1" }, { id: "node2" }],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<Longhorn options={{ nodes: true, total: true, include: ["node1"], expanded: false, labels: false }} />,
|
||||
{ settings: { target: "_self" } },
|
||||
);
|
||||
|
||||
const nodes = screen.getAllByTestId("longhorn-node");
|
||||
expect(nodes.map((n) => n.getAttribute("data-id"))).toEqual(["total", "node1"]);
|
||||
});
|
||||
|
||||
it("omits non-total nodes when options.nodes is false", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
nodes: [{ id: "total" }, { id: "node1" }],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Longhorn options={{ nodes: false, total: true }} />, { settings: { target: "_self" } });
|
||||
|
||||
const nodes = screen.getAllByTestId("longhorn-node");
|
||||
expect(nodes.map((n) => n.getAttribute("data-id"))).toEqual(["total"]);
|
||||
});
|
||||
});
|
||||
32
src/components/widgets/longhorn/node.test.jsx
Normal file
32
src/components/widgets/longhorn/node.test.jsx
Normal file
@ -0,0 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { Resource } = vi.hoisted(() => ({
|
||||
Resource: vi.fn(({ children }) => <div data-testid="lh-resource">{children}</div>),
|
||||
}));
|
||||
|
||||
vi.mock("../widget/resource", () => ({
|
||||
default: Resource,
|
||||
}));
|
||||
|
||||
vi.mock("../widget/widget_label", () => ({
|
||||
default: ({ label }) => <div data-testid="lh-label">{label}</div>,
|
||||
}));
|
||||
|
||||
import Node from "./node";
|
||||
|
||||
describe("components/widgets/longhorn/node", () => {
|
||||
it("passes calculated percentage and renders label when enabled", () => {
|
||||
const data = { node: { id: "n1", available: 25, maximum: 100 } };
|
||||
|
||||
render(<Node data={{ node: data.node }} expanded labels />);
|
||||
|
||||
expect(Resource).toHaveBeenCalledTimes(1);
|
||||
const callProps = Resource.mock.calls[0][0];
|
||||
expect(callProps.percentage).toBe(75);
|
||||
expect(callProps.expanded).toBe(true);
|
||||
expect(screen.getByTestId("lh-label")).toHaveTextContent("n1");
|
||||
});
|
||||
});
|
||||
135
src/components/widgets/openmeteo/openmeteo.test.jsx
Normal file
135
src/components/widgets/openmeteo/openmeteo.test.jsx
Normal file
@ -0,0 +1,135 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
vi.mock("swr", () => ({ default: useSWR }));
|
||||
|
||||
vi.mock("react-icons/md", () => ({
|
||||
MdLocationDisabled: (props) => <svg data-testid="location-disabled" {...props} />,
|
||||
MdLocationSearching: (props) => <svg data-testid="location-searching" {...props} />,
|
||||
}));
|
||||
|
||||
import OpenMeteo from "./openmeteo";
|
||||
|
||||
describe("components/widgets/openmeteo", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders an error state when the widget api returns an error", async () => {
|
||||
useSWR.mockReturnValue({ data: { error: "nope" }, error: undefined });
|
||||
|
||||
renderWithProviders(<OpenMeteo options={{ latitude: 1, longitude: 2 }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a location prompt when no coordinates are available", () => {
|
||||
renderWithProviders(<OpenMeteo options={{}} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("weather.current")).toBeInTheDocument();
|
||||
expect(screen.getByText("weather.allow")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("requests browser geolocation on click and then renders the updating state", async () => {
|
||||
const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 10, longitude: 20 } }));
|
||||
vi.stubGlobal("navigator", {
|
||||
permissions: { query: vi.fn().mockResolvedValue({ state: "prompt" }) },
|
||||
geolocation: { getCurrentPosition },
|
||||
});
|
||||
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<OpenMeteo options={{}} />, { settings: { target: "_self" } });
|
||||
|
||||
screen.getByRole("button").click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCurrentPosition).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.getByText("weather.updating")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the requesting state when the browser denies geolocation", async () => {
|
||||
const getCurrentPosition = vi.fn((_success, failure) => setTimeout(() => failure(), 10));
|
||||
vi.stubGlobal("navigator", {
|
||||
permissions: { query: vi.fn().mockResolvedValue({ state: "prompt" }) },
|
||||
geolocation: { getCurrentPosition },
|
||||
});
|
||||
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<OpenMeteo options={{}} />, { settings: { target: "_self" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(screen.getByTestId("location-searching")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("location-disabled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-requests geolocation when permissions are granted", async () => {
|
||||
const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 30, longitude: 40 } }));
|
||||
const query = vi.fn().mockResolvedValue({ state: "granted" });
|
||||
vi.stubGlobal("navigator", {
|
||||
permissions: { query },
|
||||
geolocation: { getCurrentPosition },
|
||||
});
|
||||
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<OpenMeteo options={{}} />, { settings: { target: "_self" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(query).toHaveBeenCalled();
|
||||
expect(getCurrentPosition).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders temperature and condition when coordinates are provided", async () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
current_weather: { temperature: 22.2, weathercode: 0, time: "2020-01-01T12:00" },
|
||||
daily: { sunrise: ["2020-01-01T06:00"], sunset: ["2020-01-01T18:00"] },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<OpenMeteo options={{ latitude: 1, longitude: 2, label: "Home", format: {} }} />, {
|
||||
settings: { target: "_self" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Home, 22.2")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("wmo.0-day")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses night conditions and fahrenheit units when configured", async () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
current_weather: { temperature: 72, weathercode: 1, time: "2020-01-01T23:00" },
|
||||
daily: { sunrise: ["2020-01-01T06:00"], sunset: ["2020-01-01T18:00"] },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<OpenMeteo options={{ latitude: 1, longitude: 2, units: "imperial", format: {} }} />, {
|
||||
settings: { target: "_self" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("72")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("wmo.1-night")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
141
src/components/widgets/openweathermap/weather.test.jsx
Normal file
141
src/components/widgets/openweathermap/weather.test.jsx
Normal file
@ -0,0 +1,141 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
vi.mock("swr", () => ({ default: useSWR }));
|
||||
|
||||
vi.mock("react-icons/md", () => ({
|
||||
MdLocationDisabled: (props) => <svg data-testid="location-disabled" {...props} />,
|
||||
MdLocationSearching: (props) => <svg data-testid="location-searching" {...props} />,
|
||||
}));
|
||||
|
||||
import OpenWeatherMap from "./weather";
|
||||
|
||||
describe("components/widgets/openweathermap", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders an error state when SWR errors or the API reports an auth error", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
renderWithProviders(<OpenWeatherMap options={{ latitude: 1, longitude: 2 }} />, { settings: { target: "_self" } });
|
||||
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { cod: 401 }, error: undefined });
|
||||
renderWithProviders(<OpenWeatherMap options={{ latitude: 1, longitude: 2 }} />, { settings: { target: "_self" } });
|
||||
expect(screen.getAllByText("widget.api_error").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders a location prompt when no coordinates are available", () => {
|
||||
renderWithProviders(<OpenWeatherMap options={{}} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("weather.current")).toBeInTheDocument();
|
||||
expect(screen.getByText("weather.allow")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("auto-requests geolocation when permissions are granted", async () => {
|
||||
const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 30, longitude: 40 } }));
|
||||
const query = vi.fn().mockResolvedValue({ state: "granted" });
|
||||
vi.stubGlobal("navigator", {
|
||||
permissions: { query },
|
||||
geolocation: { getCurrentPosition },
|
||||
});
|
||||
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<OpenWeatherMap options={{}} />, { settings: { target: "_self" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(query).toHaveBeenCalled();
|
||||
expect(getCurrentPosition).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("requests browser geolocation on click and then renders the updating state", async () => {
|
||||
const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 10, longitude: 20 } }));
|
||||
vi.stubGlobal("navigator", {
|
||||
permissions: { query: vi.fn().mockResolvedValue({ state: "prompt" }) },
|
||||
geolocation: { getCurrentPosition },
|
||||
});
|
||||
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<OpenWeatherMap options={{}} />, { settings: { target: "_self" } });
|
||||
|
||||
screen.getByRole("button").click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCurrentPosition).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.getByText("weather.updating")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the requesting state when the browser denies geolocation", async () => {
|
||||
const getCurrentPosition = vi.fn((_success, failure) => setTimeout(() => failure(), 10));
|
||||
vi.stubGlobal("navigator", {
|
||||
permissions: { query: vi.fn().mockResolvedValue({ state: "prompt" }) },
|
||||
geolocation: { getCurrentPosition },
|
||||
});
|
||||
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<OpenWeatherMap options={{}} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByTestId("location-disabled")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(screen.getByTestId("location-searching")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("location-disabled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders temperature and description when coordinates are provided", async () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
main: { temp: 71 },
|
||||
weather: [{ id: 800, description: "clear sky" }],
|
||||
dt: 10,
|
||||
sys: { sunrise: 0, sunset: 100 },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<OpenWeatherMap options={{ latitude: 1, longitude: 2, label: "Home", format: {} }} />, {
|
||||
settings: { target: "_self" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Home, 71")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("clear sky")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses night conditions and celsius units when configured", async () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
main: { temp: 10 },
|
||||
weather: [{ id: 800, description: "clear sky" }],
|
||||
dt: 200,
|
||||
sys: { sunrise: 0, sunset: 100 },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<OpenWeatherMap options={{ latitude: 1, longitude: 2, units: "metric", format: {} }} />, {
|
||||
settings: { target: "_self" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("10")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
20
src/components/widgets/queue/queueEntry.test.jsx
Normal file
20
src/components/widgets/queue/queueEntry.test.jsx
Normal file
@ -0,0 +1,20 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import QueueEntry from "./queueEntry";
|
||||
|
||||
describe("components/widgets/queue/queueEntry", () => {
|
||||
it("renders title and progress width", () => {
|
||||
const { container } = render(
|
||||
<QueueEntry title="Download" activity="Downloading" timeLeft="1m" progress={42} size="1GB" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Download")).toBeInTheDocument();
|
||||
expect(screen.getByText("1GB - Downloading - 1m")).toBeInTheDocument();
|
||||
|
||||
const bar = container.querySelector("div[style]");
|
||||
expect(bar.style.width).toBe("42%");
|
||||
});
|
||||
});
|
||||
55
src/components/widgets/resources/cpu.test.jsx
Normal file
55
src/components/widgets/resources/cpu.test.jsx
Normal file
@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR, Resource, Error } = vi.hoisted(() => ({
|
||||
useSWR: vi.fn(),
|
||||
Resource: vi.fn(() => <div data-testid="resource" />),
|
||||
Error: vi.fn(() => <div data-testid="error" />),
|
||||
}));
|
||||
|
||||
vi.mock("swr", () => ({ default: useSWR }));
|
||||
vi.mock("../widget/resource", () => ({ default: Resource }));
|
||||
vi.mock("../widget/error", () => ({ default: Error }));
|
||||
|
||||
import Cpu from "./cpu";
|
||||
|
||||
describe("components/widgets/resources/cpu", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a placeholder Resource while loading", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
render(<Cpu expanded refresh={1000} />);
|
||||
|
||||
expect(Resource).toHaveBeenCalled();
|
||||
const props = Resource.mock.calls[0][0];
|
||||
expect(props.value).toBe("-");
|
||||
expect(props.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it("renders usage/load values when data is present", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: { cpu: { usage: 12.3, load: 1.23 } },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
render(<Cpu expanded={false} />);
|
||||
|
||||
const props = Resource.mock.calls[0][0];
|
||||
expect(props.value).toBe("12.3");
|
||||
expect(props.expandedValue).toBe("1.23");
|
||||
expect(props.percentage).toBe(12.3);
|
||||
});
|
||||
|
||||
it("renders Error when SWR errors", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
render(<Cpu expanded />);
|
||||
|
||||
expect(Error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
53
src/components/widgets/resources/cputemp.test.jsx
Normal file
53
src/components/widgets/resources/cputemp.test.jsx
Normal file
@ -0,0 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR, Resource, Error } = vi.hoisted(() => ({
|
||||
useSWR: vi.fn(),
|
||||
Resource: vi.fn(() => <div data-testid="resource" />),
|
||||
Error: vi.fn(() => <div data-testid="error" />),
|
||||
}));
|
||||
|
||||
vi.mock("swr", () => ({ default: useSWR }));
|
||||
vi.mock("../widget/resource", () => ({ default: Resource }));
|
||||
vi.mock("../widget/error", () => ({ default: Error }));
|
||||
|
||||
import CpuTemp from "./cputemp";
|
||||
|
||||
describe("components/widgets/resources/cputemp", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholder when temperature data is missing", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
render(<CpuTemp expanded units="metric" />);
|
||||
|
||||
const props = Resource.mock.calls[0][0];
|
||||
expect(props.value).toBe("-");
|
||||
});
|
||||
|
||||
it("averages core temps, converts to fahrenheit and computes percentage", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: { cputemp: { main: 10, cores: [10, 10], max: 20 } },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
render(<CpuTemp expanded={false} units="imperial" tempmin={0} tempmax={-1} />);
|
||||
|
||||
const props = Resource.mock.calls[0][0];
|
||||
// common.number mock returns string of value
|
||||
expect(props.value).toBe("50");
|
||||
expect(props.expandedValue).toBe("68");
|
||||
expect(props.percentage).toBe(74);
|
||||
});
|
||||
|
||||
it("renders Error when SWR errors", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
render(<CpuTemp expanded units="metric" />);
|
||||
|
||||
expect(Error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
53
src/components/widgets/resources/disk.test.jsx
Normal file
53
src/components/widgets/resources/disk.test.jsx
Normal file
@ -0,0 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR, Resource, Error } = vi.hoisted(() => ({
|
||||
useSWR: vi.fn(),
|
||||
Resource: vi.fn(() => <div data-testid="resource" />),
|
||||
Error: vi.fn(() => <div data-testid="error" />),
|
||||
}));
|
||||
|
||||
vi.mock("swr", () => ({ default: useSWR }));
|
||||
vi.mock("../widget/resource", () => ({ default: Resource }));
|
||||
vi.mock("../widget/error", () => ({ default: Error }));
|
||||
|
||||
import Disk from "./disk";
|
||||
|
||||
describe("components/widgets/resources/disk", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a placeholder Resource while loading", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
render(<Disk options={{ disk: "/" }} expanded />);
|
||||
|
||||
const props = Resource.mock.calls[0][0];
|
||||
expect(props.value).toBe("-");
|
||||
});
|
||||
|
||||
it("computes percent used from size/available and renders bytes", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: { drive: { size: 100, available: 40 } },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
render(<Disk options={{ disk: "/data" }} diskUnits="bytes" expanded={false} />);
|
||||
|
||||
const props = Resource.mock.calls[0][0];
|
||||
expect(props.value).toBe("40");
|
||||
expect(props.expandedValue).toBe("100");
|
||||
expect(props.percentage).toBe(60);
|
||||
});
|
||||
|
||||
it("renders Error when SWR errors", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
render(<Disk options={{ disk: "/" }} expanded />);
|
||||
|
||||
expect(Error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
53
src/components/widgets/resources/memory.test.jsx
Normal file
53
src/components/widgets/resources/memory.test.jsx
Normal file
@ -0,0 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR, Resource, Error } = vi.hoisted(() => ({
|
||||
useSWR: vi.fn(),
|
||||
Resource: vi.fn(() => <div data-testid="resource" />),
|
||||
Error: vi.fn(() => <div data-testid="error" />),
|
||||
}));
|
||||
|
||||
vi.mock("swr", () => ({ default: useSWR }));
|
||||
vi.mock("../widget/resource", () => ({ default: Resource }));
|
||||
vi.mock("../widget/error", () => ({ default: Error }));
|
||||
|
||||
import Memory from "./memory";
|
||||
|
||||
describe("components/widgets/resources/memory", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a placeholder Resource while loading", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
render(<Memory expanded />);
|
||||
|
||||
const props = Resource.mock.calls[0][0];
|
||||
expect(props.value).toBe("-");
|
||||
});
|
||||
|
||||
it("calculates percentage from active/total and renders available/total", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: { memory: { available: 10, total: 20, active: 5 } },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
render(<Memory expanded={false} />);
|
||||
|
||||
const props = Resource.mock.calls[0][0];
|
||||
expect(props.value).toBe("10");
|
||||
expect(props.expandedValue).toBe("20");
|
||||
expect(props.percentage).toBe(25);
|
||||
});
|
||||
|
||||
it("renders Error when SWR errors", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
render(<Memory expanded />);
|
||||
|
||||
expect(Error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
57
src/components/widgets/resources/network.test.jsx
Normal file
57
src/components/widgets/resources/network.test.jsx
Normal file
@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR, Resource, Error } = vi.hoisted(() => ({
|
||||
useSWR: vi.fn(),
|
||||
Resource: vi.fn(() => <div data-testid="resource" />),
|
||||
Error: vi.fn(() => <div data-testid="error" />),
|
||||
}));
|
||||
|
||||
vi.mock("swr", () => ({ default: useSWR }));
|
||||
vi.mock("../widget/resource", () => ({ default: Resource }));
|
||||
vi.mock("../widget/error", () => ({ default: Error }));
|
||||
|
||||
import Network from "./network";
|
||||
|
||||
describe("components/widgets/resources/network", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("normalizes options.network=true to default interfaceName in the request", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
render(<Network options={{ network: true }} />);
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith(expect.stringContaining("interfaceName=default"), expect.any(Object));
|
||||
});
|
||||
|
||||
it("renders rates and usage percentage when data is present", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
network: { rx_sec: 3, tx_sec: 1, rx_bytes: 30, tx_bytes: 10 },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
render(<Network options={{ network: "en0", expanded: true }} />);
|
||||
|
||||
const props = Resource.mock.calls[0][0];
|
||||
expect(props.value).toContain("1");
|
||||
expect(props.value).toContain("↑");
|
||||
expect(props.label).toContain("3");
|
||||
expect(props.label).toContain("↓");
|
||||
expect(props.percentage).toBe(75);
|
||||
expect(props.wide).toBe(true);
|
||||
});
|
||||
|
||||
it("renders Error when SWR errors", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
render(<Network options={{ network: "en0" }} />);
|
||||
|
||||
expect(Error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
49
src/components/widgets/resources/resources.test.jsx
Normal file
49
src/components/widgets/resources/resources.test.jsx
Normal file
@ -0,0 +1,49 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
vi.mock("./cpu", () => ({ default: () => <div data-testid="resources-cpu" /> }));
|
||||
vi.mock("./memory", () => ({ default: () => <div data-testid="resources-memory" /> }));
|
||||
vi.mock("./disk", () => ({ default: ({ options }) => <div data-testid="resources-disk" data-disk={options.disk} /> }));
|
||||
vi.mock("./network", () => ({ default: () => <div data-testid="resources-network" /> }));
|
||||
vi.mock("./cputemp", () => ({ default: () => <div data-testid="resources-cputemp" /> }));
|
||||
vi.mock("./uptime", () => ({ default: () => <div data-testid="resources-uptime" /> }));
|
||||
|
||||
import Resources from "./resources";
|
||||
|
||||
describe("components/widgets/resources", () => {
|
||||
it("renders selected resource blocks and an optional label", () => {
|
||||
renderWithProviders(
|
||||
<Resources
|
||||
options={{
|
||||
cpu: true,
|
||||
memory: true,
|
||||
disk: ["/", "/data"],
|
||||
network: true,
|
||||
cputemp: true,
|
||||
uptime: true,
|
||||
label: "Host A",
|
||||
}}
|
||||
/>,
|
||||
{ settings: { target: "_self" } },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("resources-cpu")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("resources-memory")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("resources-disk")).toHaveLength(2);
|
||||
expect(screen.getByTestId("resources-network")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("resources-cputemp")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("resources-uptime")).toBeInTheDocument();
|
||||
expect(screen.getByText("Host A")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a single disk block when disk is not an array", () => {
|
||||
renderWithProviders(<Resources options={{ disk: true }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getAllByTestId("resources-disk")).toHaveLength(1);
|
||||
expect(screen.getByTestId("resources-disk").getAttribute("data-disk")).toBe("true");
|
||||
});
|
||||
});
|
||||
54
src/components/widgets/resources/uptime.test.jsx
Normal file
54
src/components/widgets/resources/uptime.test.jsx
Normal file
@ -0,0 +1,54 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR, Resource, Error } = vi.hoisted(() => ({
|
||||
useSWR: vi.fn(),
|
||||
Resource: vi.fn(() => <div data-testid="resource" />),
|
||||
Error: vi.fn(() => <div data-testid="error" />),
|
||||
}));
|
||||
|
||||
vi.mock("swr", () => ({ default: useSWR }));
|
||||
vi.mock("../widget/resource", () => ({ default: Resource }));
|
||||
vi.mock("../widget/error", () => ({ default: Error }));
|
||||
|
||||
import Uptime from "./uptime";
|
||||
|
||||
describe("components/widgets/resources/uptime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a placeholder while loading", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
render(<Uptime />);
|
||||
expect(Resource).toHaveBeenCalled();
|
||||
expect(Resource.mock.calls[0][0].value).toBe("-");
|
||||
});
|
||||
|
||||
it("renders formatted duration and sets percentage based on current seconds", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2020-01-01T00:00:30.000Z"));
|
||||
|
||||
useSWR.mockReturnValue({ data: { uptime: 1234 }, error: undefined });
|
||||
render(<Uptime />);
|
||||
|
||||
const props = Resource.mock.calls[0][0];
|
||||
expect(props.value).toBe("1234");
|
||||
expect(props.percentage).toBe("50");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders Error when SWR errors", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
render(<Uptime />);
|
||||
|
||||
expect(Error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
18
src/components/widgets/resources/usage-bar.test.jsx
Normal file
18
src/components/widgets/resources/usage-bar.test.jsx
Normal file
@ -0,0 +1,18 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import UsageBar from "./usage-bar";
|
||||
|
||||
describe("components/widgets/resources/usage-bar", () => {
|
||||
it("normalizes percent to [0, 100] and applies width style", () => {
|
||||
const { container: c0 } = render(<UsageBar percent={-5} />);
|
||||
const inner0 = c0.querySelector("div > div > div");
|
||||
expect(inner0.style.width).toBe("0%");
|
||||
|
||||
const { container: c1 } = render(<UsageBar percent={150} />);
|
||||
const inner1 = c1.querySelector("div > div > div");
|
||||
expect(inner1.style.width).toBe("100%");
|
||||
});
|
||||
});
|
||||
@ -82,12 +82,10 @@ export function getStoredProvider() {
|
||||
export default function Search({ options }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const availableProviderIds = getAvailableProviderIds(options);
|
||||
const availableProviderIds = getAvailableProviderIds(options) ?? [];
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedProvider, setSelectedProvider] = useState(
|
||||
searchProviders[availableProviderIds[0] ?? searchProviders.google],
|
||||
);
|
||||
const [selectedProvider, setSelectedProvider] = useState(searchProviders[availableProviderIds[0] ?? "google"]);
|
||||
const [searchSuggestions, setSearchSuggestions] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -153,7 +151,7 @@ export default function Search({ options }) {
|
||||
}
|
||||
};
|
||||
|
||||
if (!availableProviderIds) {
|
||||
if (!availableProviderIds.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
198
src/components/widgets/search/search.test.jsx
Normal file
198
src/components/widgets/search/search.test.jsx
Normal file
@ -0,0 +1,198 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
// HeadlessUI is hard to test reliably; stub the primitives to simple pass-through components.
|
||||
vi.mock("@headlessui/react", async () => {
|
||||
const React = await import("react");
|
||||
const { Fragment, createContext, useContext } = React;
|
||||
const ListboxContext = createContext(null);
|
||||
|
||||
function passthrough({ as: As = "div", children, ...props }) {
|
||||
if (As === Fragment) return <>{typeof children === "function" ? children({ active: false }) : children}</>;
|
||||
const content = typeof children === "function" ? children({ active: false }) : children;
|
||||
return <As {...props}>{content}</As>;
|
||||
}
|
||||
|
||||
return {
|
||||
Combobox: passthrough,
|
||||
ComboboxInput: (props) => <input {...props} />,
|
||||
ComboboxOption: passthrough,
|
||||
ComboboxOptions: passthrough,
|
||||
Listbox: ({ value, onChange, children, ...props }) => (
|
||||
<ListboxContext.Provider value={{ value, onChange }}>
|
||||
<div {...props}>{typeof children === "function" ? children({}) : children}</div>
|
||||
</ListboxContext.Provider>
|
||||
),
|
||||
ListboxButton: (props) => <button type="button" {...props} />,
|
||||
ListboxOption: ({ as: _as, value, children, ...props }) => {
|
||||
const ctx = useContext(ListboxContext);
|
||||
const content = typeof children === "function" ? children({ active: false }) : children;
|
||||
return (
|
||||
<div role="option" data-provider={value?.name} onClick={() => ctx?.onChange?.(value)} {...props}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
ListboxOptions: passthrough,
|
||||
Transition: ({ children }) => <>{children}</>,
|
||||
};
|
||||
});
|
||||
|
||||
import Search from "./search";
|
||||
|
||||
describe("components/widgets/search", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("opens a search URL when Enter is pressed", () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
|
||||
renderWithProviders(<Search options={{ provider: ["google"], showSearchSuggestions: false, target: "_self" }} />, {
|
||||
settings: { target: "_blank" },
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText("search.placeholder");
|
||||
fireEvent.change(input, { target: { value: "hello world" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith("https://www.google.com/search?q=hello%20world", "_self");
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("accepts provider configured as a string", () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
|
||||
renderWithProviders(
|
||||
<Search options={{ provider: "duckduckgo", showSearchSuggestions: false, target: "_self" }} />,
|
||||
{
|
||||
settings: {},
|
||||
},
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("search.placeholder");
|
||||
fireEvent.change(input, { target: { value: "hello" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith("https://duckduckgo.com/?q=hello", "_self");
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns null when the configured provider list contains no supported providers", () => {
|
||||
const { container } = renderWithProviders(<Search options={{ provider: "nope", showSearchSuggestions: false }} />, {
|
||||
settings: {},
|
||||
});
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("stores the selected provider in localStorage when it is changed", async () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
|
||||
renderWithProviders(
|
||||
<Search options={{ provider: ["google", "duckduckgo"], showSearchSuggestions: false, target: "_self" }} />,
|
||||
{
|
||||
settings: {},
|
||||
},
|
||||
);
|
||||
|
||||
const option = document.querySelector('[data-provider="DuckDuckGo"]');
|
||||
expect(option).not.toBeNull();
|
||||
fireEvent.click(option);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("search-name")).toBe("DuckDuckGo");
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText("search.placeholder");
|
||||
fireEvent.change(input, { target: { value: "hello" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith("https://duckduckgo.com/?q=hello", "_self");
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses a stored provider from localStorage when it is available and allowed", () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
|
||||
localStorage.setItem("search-name", "DuckDuckGo");
|
||||
|
||||
renderWithProviders(
|
||||
<Search options={{ provider: ["google", "duckduckgo"], showSearchSuggestions: false, target: "_self" }} />,
|
||||
{
|
||||
settings: {},
|
||||
},
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("search.placeholder");
|
||||
fireEvent.change(input, { target: { value: "hello" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith("https://duckduckgo.com/?q=hello", "_self");
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses a custom provider URL when the selected provider is custom", () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
|
||||
renderWithProviders(
|
||||
<Search
|
||||
options={{
|
||||
provider: ["custom"],
|
||||
url: "https://example.com/search?q=",
|
||||
showSearchSuggestions: false,
|
||||
target: "_self",
|
||||
}}
|
||||
/>,
|
||||
{ settings: {} },
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("search.placeholder");
|
||||
fireEvent.change(input, { target: { value: "hello world" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith("https://example.com/search?q=hello%20world", "_self");
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("fetches search suggestions and triggers a search when a suggestion is selected", async () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchSpy = vi.fn(async () => ({
|
||||
json: async () => ["hel", ["hello", "help", "helm", "helium", "held"]],
|
||||
}));
|
||||
// eslint-disable-next-line no-global-assign
|
||||
fetch = fetchSpy;
|
||||
|
||||
renderWithProviders(<Search options={{ provider: ["google"], showSearchSuggestions: true, target: "_self" }} />, {
|
||||
settings: {},
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText("search.placeholder");
|
||||
fireEvent.change(input, { target: { value: "hel" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/search/searchSuggestion?query=hel&providerName=Google"),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[value="hello"]')).toBeTruthy();
|
||||
});
|
||||
expect(document.querySelector('[value="held"]')).toBeNull();
|
||||
fireEvent.mouseDown(document.querySelector('[value="hello"]'));
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith("https://www.google.com/search?q=hello", "_self");
|
||||
|
||||
openSpy.mockRestore();
|
||||
// eslint-disable-next-line no-global-assign
|
||||
fetch = originalFetch;
|
||||
});
|
||||
});
|
||||
72
src/components/widgets/stocks/stocks.test.jsx
Normal file
72
src/components/widgets/stocks/stocks.test.jsx
Normal file
@ -0,0 +1,72 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
vi.mock("swr", () => ({ default: useSWR }));
|
||||
|
||||
import Stocks from "./stocks";
|
||||
|
||||
describe("components/widgets/stocks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders an error widget when the api call fails", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
renderWithProviders(<Stocks options={{}} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a loading state while waiting for data", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<Stocks options={{}} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText(/stocks\.loading/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles between price and percent change on click", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
stocks: [
|
||||
{ ticker: "NASDAQ:AAPL", currentPrice: 123.45, percentChange: 1.23 },
|
||||
{ ticker: "MSFT", currentPrice: 99.99, percentChange: -0.5 },
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Stocks options={{ color: false }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("AAPL")).toBeInTheDocument();
|
||||
expect(screen.getByText("123.45")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(screen.getByText("1.23%")).toBeInTheDocument();
|
||||
expect(screen.getByText("-0.5%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows api_error for null prices and uses colored classes when enabled", () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
stocks: [{ ticker: "NASDAQ:AAPL", currentPrice: null, percentChange: -1 }],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Stocks options={{}} />, { settings: { target: "_self" } });
|
||||
|
||||
const apiError = screen.getByText("widget.api_error");
|
||||
expect(apiError.className).toContain("text-rose");
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
const percent = screen.getByText("-1%");
|
||||
expect(percent.className).toContain("text-rose");
|
||||
});
|
||||
});
|
||||
261
src/components/widgets/unifi_console/unifi_console.test.jsx
Normal file
261
src/components/widgets/unifi_console/unifi_console.test.jsx
Normal file
@ -0,0 +1,261 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({
|
||||
default: useWidgetAPI,
|
||||
}));
|
||||
|
||||
vi.mock("react-icons/bi", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
BiWifi: (props) => <svg data-testid="bi-wifi" {...props} />,
|
||||
BiNetworkChart: (props) => <svg data-testid="bi-network-chart" {...props} />,
|
||||
BiError: (props) => <svg data-testid="bi-error" {...props} />,
|
||||
BiCheckCircle: (props) => <svg data-testid="bi-check-circle" {...props} />,
|
||||
BiXCircle: (props) => <svg data-testid="bi-x-circle" {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
import UnifiConsole from "./unifi_console";
|
||||
|
||||
describe("components/widgets/unifi_console", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders an api error state when the widget api call fails", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a wait state when no site is available yet", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("unifi.wait")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders site name and uptime when data is available", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
name: "default",
|
||||
desc: "Home",
|
||||
health: [
|
||||
{
|
||||
subsystem: "wan",
|
||||
status: "ok",
|
||||
gw_name: "Router",
|
||||
"gw_system-stats": { uptime: 172800 },
|
||||
},
|
||||
{ subsystem: "lan", status: "unknown" },
|
||||
{ subsystem: "wlan", status: "unknown" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("Router")).toBeInTheDocument();
|
||||
// common.number is mocked to return the numeric value as a string.
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByText("unifi.days")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects a site by description when options.site is set", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
name: "default",
|
||||
desc: "Other",
|
||||
health: [
|
||||
{ subsystem: "wan", status: "unknown" },
|
||||
{ subsystem: "lan", status: "unknown" },
|
||||
{ subsystem: "wlan", status: "unknown" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "site-2",
|
||||
desc: "My Site",
|
||||
health: [
|
||||
{ subsystem: "wan", status: "ok", gw_name: "My GW", "gw_system-stats": { uptime: 86400 } },
|
||||
{ subsystem: "lan", status: "unknown" },
|
||||
{ subsystem: "wlan", status: "unknown" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<UnifiConsole options={{ index: 0, site: "My Site" }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("My GW")).toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows wlan user/device counts when wlan is available and lan is unknown", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
name: "default",
|
||||
desc: "Home",
|
||||
health: [
|
||||
{ subsystem: "wan", status: "unknown" },
|
||||
{ subsystem: "lan", status: "unknown" },
|
||||
{ subsystem: "wlan", status: "ok", num_user: 3, num_adopted: 10 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("Home")).toBeInTheDocument();
|
||||
expect(screen.getByText("unifi.wlan")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("unifi.devices")).toBeInTheDocument();
|
||||
expect(screen.getByText("10")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an empty data hint when all subsystems are unknown and uptime is missing", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
name: "default",
|
||||
desc: "Home",
|
||||
health: [
|
||||
{ subsystem: "wan", status: "unknown" },
|
||||
{ subsystem: "lan", status: "unknown" },
|
||||
{ subsystem: "wlan", status: "unknown" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("unifi.empty_data")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows wan state when wan is available but reports a non-ok status", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
name: "default",
|
||||
desc: "Home",
|
||||
health: [
|
||||
{ subsystem: "wan", status: "error", gw_name: "Router" },
|
||||
{ subsystem: "lan", status: "unknown" },
|
||||
{ subsystem: "wlan", status: "unknown" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("Router")).toBeInTheDocument();
|
||||
expect(screen.getByText("unifi.wan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows wlan down state when only wlan is available", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
name: "default",
|
||||
desc: "Home",
|
||||
health: [
|
||||
{ subsystem: "wan", status: "unknown" },
|
||||
{ subsystem: "lan", status: "unknown" },
|
||||
{ subsystem: "wlan", status: "error", num_user: 1, num_adopted: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("unifi.wlan")).toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows lan user/device counts when only lan is available", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
name: "default",
|
||||
desc: "Home",
|
||||
health: [
|
||||
{ subsystem: "wan", status: "unknown" },
|
||||
{ subsystem: "lan", status: "ok", num_user: 2, num_adopted: 5 },
|
||||
{ subsystem: "wlan", status: "unknown" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("unifi.lan")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByText("5")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("unifi.devices")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a lan down state when only lan is available and reports a non-ok status", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
name: "default",
|
||||
desc: "Home",
|
||||
health: [
|
||||
{ subsystem: "wan", status: "unknown" },
|
||||
{ subsystem: "lan", status: "error", num_user: 1, num_adopted: 2 },
|
||||
{ subsystem: "wlan", status: "unknown" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("unifi.lan")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("bi-x-circle")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
146
src/components/widgets/weather/weather.test.jsx
Normal file
146
src/components/widgets/weather/weather.test.jsx
Normal file
@ -0,0 +1,146 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
vi.mock("swr", () => ({ default: useSWR }));
|
||||
|
||||
vi.mock("react-icons/md", () => ({
|
||||
MdLocationDisabled: (props) => <svg data-testid="location-disabled" {...props} />,
|
||||
MdLocationSearching: (props) => <svg data-testid="location-searching" {...props} />,
|
||||
}));
|
||||
|
||||
import WeatherApi from "./weather";
|
||||
|
||||
describe("components/widgets/weather", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders an error state when SWR errors or the API payload indicates an error", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
renderWithProviders(<WeatherApi options={{ latitude: 1, longitude: 2 }} />, { settings: { target: "_self" } });
|
||||
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { error: "nope" }, error: undefined });
|
||||
renderWithProviders(<WeatherApi options={{ latitude: 1, longitude: 2 }} />, { settings: { target: "_self" } });
|
||||
expect(screen.getAllByText("widget.api_error").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders a location prompt when no coordinates are available", () => {
|
||||
renderWithProviders(<WeatherApi options={{}} />, { settings: { target: "_self" } });
|
||||
|
||||
expect(screen.getByText("weather.current")).toBeInTheDocument();
|
||||
expect(screen.getByText("weather.allow")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("auto-requests geolocation when permissions are granted", async () => {
|
||||
const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 30, longitude: 40 } }));
|
||||
const query = vi.fn().mockResolvedValue({ state: "granted" });
|
||||
vi.stubGlobal("navigator", {
|
||||
permissions: { query },
|
||||
geolocation: { getCurrentPosition },
|
||||
});
|
||||
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<WeatherApi options={{}} />, { settings: { target: "_self" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(query).toHaveBeenCalled();
|
||||
expect(getCurrentPosition).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("requests browser geolocation on click and then renders the updating state", async () => {
|
||||
const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 10, longitude: 20 } }));
|
||||
vi.stubGlobal("navigator", {
|
||||
permissions: { query: vi.fn().mockResolvedValue({ state: "prompt" }) },
|
||||
geolocation: { getCurrentPosition },
|
||||
});
|
||||
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<WeatherApi options={{}} />, { settings: { target: "_self" } });
|
||||
|
||||
screen.getByRole("button").click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCurrentPosition).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.getByText("weather.updating")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the requesting state when the browser denies geolocation", async () => {
|
||||
const getCurrentPosition = vi.fn((_success, failure) => setTimeout(() => failure(), 10));
|
||||
vi.stubGlobal("navigator", {
|
||||
permissions: { query: vi.fn().mockResolvedValue({ state: "prompt" }) },
|
||||
geolocation: { getCurrentPosition },
|
||||
});
|
||||
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<WeatherApi options={{}} />, { settings: { target: "_self" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(screen.getByTestId("location-searching")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("location-disabled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders temperature and condition when coordinates are provided", async () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
current: {
|
||||
temp_c: 21.5,
|
||||
temp_f: 70.7,
|
||||
is_day: 1,
|
||||
condition: { code: 1000, text: "Sunny" },
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<WeatherApi options={{ latitude: 1, longitude: 2, units: "metric", label: "Home", format: {} }} />,
|
||||
{ settings: { target: "_self" } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Home, 21.5")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Sunny")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses fahrenheit and night conditions when configured", async () => {
|
||||
useSWR.mockReturnValue({
|
||||
data: {
|
||||
current: {
|
||||
temp_c: 21.5,
|
||||
temp_f: 70.7,
|
||||
is_day: 0,
|
||||
condition: { code: 1000, text: "Clear" },
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<WeatherApi options={{ latitude: 1, longitude: 2, units: "imperial", format: {} }} />, {
|
||||
settings: { target: "_self" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("70.7")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Clear")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
58
src/components/widgets/widget.test.jsx
Normal file
58
src/components/widgets/widget.test.jsx
Normal file
@ -0,0 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { dynamic } = vi.hoisted(() => {
|
||||
const dynamic = vi.fn((loader, opts) => {
|
||||
const loaderStr = loader.toString();
|
||||
const ssr = opts?.ssr === false ? "false" : "true";
|
||||
|
||||
return function DynamicWidget({ options }) {
|
||||
return (
|
||||
<div
|
||||
data-testid="dynamic-widget"
|
||||
data-loader={loaderStr}
|
||||
data-ssr={ssr}
|
||||
data-options={JSON.stringify(options)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
return { dynamic };
|
||||
});
|
||||
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: dynamic,
|
||||
}));
|
||||
|
||||
vi.mock("components/errorboundry", () => ({
|
||||
default: ({ children }) => <div data-testid="error-boundary">{children}</div>,
|
||||
}));
|
||||
|
||||
import Widget from "./widget";
|
||||
|
||||
describe("components/widgets/widget", () => {
|
||||
it("renders the mapped widget component and forwards style into options", () => {
|
||||
render(
|
||||
<Widget widget={{ type: "search", options: { provider: ["google"] } }} style={{ header: "boxedWidgets" }} />,
|
||||
);
|
||||
|
||||
const boundary = screen.getByTestId("error-boundary");
|
||||
expect(boundary).toBeInTheDocument();
|
||||
|
||||
const el = screen.getByTestId("dynamic-widget");
|
||||
expect(el.getAttribute("data-loader")).toContain("search/search");
|
||||
|
||||
const forwarded = JSON.parse(el.getAttribute("data-options"));
|
||||
expect(forwarded.provider).toEqual(["google"]);
|
||||
expect(forwarded.style).toEqual({ header: "boxedWidgets" });
|
||||
});
|
||||
|
||||
it("renders a missing message when widget type is unknown", () => {
|
||||
render(<Widget widget={{ type: "nope", options: {} }} style={{}} />);
|
||||
expect(screen.getByText("Missing")).toBeInTheDocument();
|
||||
expect(screen.getByText("nope")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
76
src/components/widgets/widget/container.test.jsx
Normal file
76
src/components/widgets/widget/container.test.jsx
Normal file
@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
import Container, { getAllClasses } from "./container";
|
||||
import PrimaryText from "./primary_text";
|
||||
import Raw from "./raw";
|
||||
import SecondaryText from "./secondary_text";
|
||||
import WidgetIcon from "./widget_icon";
|
||||
|
||||
function FakeIcon(props) {
|
||||
return <svg data-testid="fake-icon" {...props} />;
|
||||
}
|
||||
|
||||
describe("components/widgets/widget/container", () => {
|
||||
it("getAllClasses supports boxedWidgets + cardBlur and right alignment", () => {
|
||||
const boxed = getAllClasses({ style: { header: "boxedWidgets", cardBlur: "md" } }, "x");
|
||||
expect(boxed).toContain("backdrop-blur-md");
|
||||
expect(boxed).toContain("x");
|
||||
|
||||
const right = getAllClasses({ style: { isRightAligned: true } }, "y");
|
||||
expect(right).toContain("justify-center");
|
||||
expect(right).toContain("y");
|
||||
expect(right).not.toContain("max-w:full");
|
||||
});
|
||||
|
||||
it("renders an anchor when href is provided and prefers options.target over settings.target", () => {
|
||||
renderWithProviders(
|
||||
<Container options={{ href: "http://example", target: "_self" }}>
|
||||
<WidgetIcon icon={FakeIcon} />
|
||||
<PrimaryText>P</PrimaryText>
|
||||
<SecondaryText>S</SecondaryText>
|
||||
<Raw>
|
||||
<div data-testid="bottom">B</div>
|
||||
</Raw>
|
||||
</Container>,
|
||||
{ settings: { target: "_blank" } },
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link.getAttribute("href")).toBe("http://example");
|
||||
expect(link.getAttribute("target")).toBe("_self");
|
||||
expect(screen.getByTestId("fake-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("P")).toBeInTheDocument();
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("bottom")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only bottom content when children are a single Raw element", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<Container options={{}}>
|
||||
<Raw>
|
||||
<div data-testid="only-bottom">B</div>
|
||||
</Raw>
|
||||
</Container>,
|
||||
{ settings: { target: "_self" } },
|
||||
);
|
||||
|
||||
expect(container.querySelector(".widget-inner")).toBeNull();
|
||||
expect(screen.getByTestId("only-bottom")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not crash when clicked (href case is normal link)", () => {
|
||||
renderWithProviders(
|
||||
<Container options={{ href: "http://example" }}>
|
||||
<Raw>
|
||||
<div>Bottom</div>
|
||||
</Raw>
|
||||
</Container>,
|
||||
{ settings: { target: "_self" } },
|
||||
);
|
||||
});
|
||||
});
|
||||
23
src/components/widgets/widget/container_button.test.jsx
Normal file
23
src/components/widgets/widget/container_button.test.jsx
Normal file
@ -0,0 +1,23 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ContainerButton from "./container_button";
|
||||
import Raw from "./raw";
|
||||
|
||||
describe("components/widgets/widget/container_button", () => {
|
||||
it("invokes callback on click", () => {
|
||||
const cb = vi.fn();
|
||||
render(
|
||||
<ContainerButton options={{}} callback={cb}>
|
||||
<Raw>
|
||||
<div>child</div>
|
||||
</Raw>
|
||||
</ContainerButton>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
23
src/components/widgets/widget/container_form.test.jsx
Normal file
23
src/components/widgets/widget/container_form.test.jsx
Normal file
@ -0,0 +1,23 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ContainerForm from "./container_form";
|
||||
|
||||
describe("components/widgets/widget/container_form", () => {
|
||||
it("calls callback on submit", () => {
|
||||
const cb = vi.fn((e) => e.preventDefault());
|
||||
|
||||
const { container } = render(
|
||||
<ContainerForm options={{}} callback={cb}>
|
||||
{[<div key="c">child</div>]}
|
||||
</ContainerForm>,
|
||||
);
|
||||
|
||||
const form = container.querySelector("form");
|
||||
fireEvent.submit(form);
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user