Refactor components for improved readability and consistency

- Added missing newlines at the end of files in MarkdownContent.tsx and RootCauseCard.tsx
- Reformatted shadowColor and color properties in NotesSection.tsx, SegmentedControl.tsx, MainTabNavigator.tsx, HomeScreen.tsx for better readability
- Enhanced code formatting in SectionHeader.tsx and OnCallStackNavigator.tsx for consistency
- Improved readability of getEntityId function in useAllProjectOnCallPolicies.ts
- Refactored conditional rendering in AlertDetailScreen.tsx, AlertEpisodeDetailScreen.tsx, IncidentDetailScreen.tsx, and IncidentEpisodeDetailScreen.tsx for better clarity
This commit is contained in:
Nawaz Dhandala
2026-02-15 11:47:32 +00:00
parent b89ff11db8
commit d25a97fe17
40 changed files with 1016 additions and 732 deletions

View File

@@ -11,14 +11,17 @@ export function registerConfigCommands(program: Command): void {
.command("login")
.description("Authenticate with a OneUptime instance")
.argument("<api-key>", "API key for authentication")
.argument("<instance-url>", "OneUptime instance URL (e.g. https://oneuptime.com)")
.option(
"--context-name <name>",
"Name for this context",
"default",
.argument(
"<instance-url>",
"OneUptime instance URL (e.g. https://oneuptime.com)",
)
.option("--context-name <name>", "Name for this context", "default")
.action(
(apiKey: string, instanceUrl: string, options: { contextName: string }) => {
(
apiKey: string,
instanceUrl: string,
options: { contextName: string },
) => {
try {
const context: CLIContext = {
name: options.contextName,
@@ -68,20 +71,17 @@ export function registerConfigCommands(program: Command): void {
process.argv.includes("--no-color");
const table: Table.Table = new Table({
head: ["", "Name", "URL"].map((h: string) =>
noColor ? h : chalk.cyan(h),
),
head: ["", "Name", "URL"].map((h: string) => {
return noColor ? h : chalk.cyan(h);
}),
style: { head: [], border: [] },
});
for (const ctx of contexts) {
table.push([
ctx.isCurrent ? "*" : "",
ctx.name,
ctx.apiUrl,
]);
table.push([ctx.isCurrent ? "*" : "", ctx.name, ctx.apiUrl]);
}
// eslint-disable-next-line no-console
console.log(table.toString());
});
@@ -93,9 +93,7 @@ export function registerConfigCommands(program: Command): void {
ConfigManager.setCurrentContext(name);
printSuccess(`Switched to context "${name}".`);
} catch (error) {
printError(
error instanceof Error ? error.message : String(error),
);
printError(error instanceof Error ? error.message : String(error));
process.exit(1);
}
});
@@ -119,8 +117,11 @@ export function registerConfigCommands(program: Command): void {
ctx.apiKey.substring(ctx.apiKey.length - 4)
: "****";
// eslint-disable-next-line no-console
console.log(`Context: ${ctx.name}`);
// eslint-disable-next-line no-console
console.log(`URL: ${ctx.apiUrl}`);
// eslint-disable-next-line no-console
console.log(`API Key: ${maskedKey}`);
});
@@ -132,9 +133,7 @@ export function registerConfigCommands(program: Command): void {
ConfigManager.removeContext(name);
printSuccess(`Context "${name}" deleted.`);
} catch (error) {
printError(
error instanceof Error ? error.message : String(error),
);
printError(error instanceof Error ? error.message : String(error));
process.exit(1);
}
});

View File

@@ -3,10 +3,9 @@ import DatabaseModels from "Common/Models/DatabaseModels/Index";
import AnalyticsModels from "Common/Models/AnalyticsModels/Index";
import BaseModel from "Common/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
import AnalyticsBaseModel from "Common/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
import { ResourceInfo } from "../Types/CLITypes";
import { ResourceInfo, ResolvedCredentials } from "../Types/CLITypes";
import { executeApiRequest, ApiOperation } from "../Core/ApiClient";
import { CLIOptions, getResolvedCredentials } from "../Core/ConfigManager";
import { ResolvedCredentials } from "../Types/CLITypes";
import { formatOutput, printSuccess } from "../Core/OutputFormatter";
import { handleError } from "../Core/ErrorHandler";
import { generateAllFieldsSelect } from "../Utils/SelectFieldGenerator";
@@ -118,8 +117,7 @@ function registerListCommand(
}) => {
try {
const parentOpts: CLIOptions = getParentOptions(resourceCmd);
const creds: ResolvedCredentials =
getResolvedCredentials(parentOpts);
const creds: ResolvedCredentials = getResolvedCredentials(parentOpts);
const select: JSONObject = generateAllFieldsSelect(
resource.tableName,
resource.modelType,
@@ -130,15 +128,11 @@ function registerListCommand(
apiKey: creds.apiKey,
apiPath: resource.apiPath,
operation: "list" as ApiOperation,
query: options.query
? parseJsonArg(options.query)
: undefined,
query: options.query ? parseJsonArg(options.query) : undefined,
select,
skip: parseInt(options.skip, 10),
limit: parseInt(options.limit, 10),
sort: options.sort
? parseJsonArg(options.sort)
: undefined,
sort: options.sort ? parseJsonArg(options.sort) : undefined,
});
// Extract data array from response
@@ -147,6 +141,7 @@ function registerListCommand(
? ((result as JSONObject)["data"] as JSONValue) || result
: result;
// eslint-disable-next-line no-console
console.log(formatOutput(responseData, options.output));
} catch (error) {
handleError(error);
@@ -166,8 +161,7 @@ function registerGetCommand(
.action(async (id: string, options: { output?: string }) => {
try {
const parentOpts: CLIOptions = getParentOptions(resourceCmd);
const creds: ResolvedCredentials =
getResolvedCredentials(parentOpts);
const creds: ResolvedCredentials = getResolvedCredentials(parentOpts);
const select: JSONObject = generateAllFieldsSelect(
resource.tableName,
resource.modelType,
@@ -182,6 +176,7 @@ function registerGetCommand(
select,
});
// eslint-disable-next-line no-console
console.log(formatOutput(result, options.output));
} catch (error) {
handleError(error);
@@ -200,31 +195,21 @@ function registerCreateCommand(
.option("--file <path>", "Read resource data from a JSON file")
.option("-o, --output <format>", "Output format: json, table, wide")
.action(
async (options: {
data?: string;
file?: string;
output?: string;
}) => {
async (options: { data?: string; file?: string; output?: string }) => {
try {
let data: JSONObject;
if (options.file) {
const fileContent: string = fs.readFileSync(
options.file,
"utf-8",
);
const fileContent: string = fs.readFileSync(options.file, "utf-8");
data = JSON.parse(fileContent) as JSONObject;
} else if (options.data) {
data = parseJsonArg(options.data);
} else {
throw new Error(
"Either --data or --file is required for create.",
);
throw new Error("Either --data or --file is required for create.");
}
const parentOpts: CLIOptions = getParentOptions(resourceCmd);
const creds: ResolvedCredentials =
getResolvedCredentials(parentOpts);
const creds: ResolvedCredentials = getResolvedCredentials(parentOpts);
const result: JSONValue = await executeApiRequest({
apiUrl: creds.apiUrl,
@@ -234,6 +219,7 @@ function registerCreateCommand(
data,
});
// eslint-disable-next-line no-console
console.log(formatOutput(result, options.output));
} catch (error) {
handleError(error);
@@ -251,32 +237,27 @@ function registerUpdateCommand(
.description(`Update an existing ${resource.singularName}`)
.requiredOption("--data <json>", "Fields to update as JSON")
.option("-o, --output <format>", "Output format: json, table, wide")
.action(
async (
id: string,
options: { data: string; output?: string },
) => {
try {
const data: JSONObject = parseJsonArg(options.data);
const parentOpts: CLIOptions = getParentOptions(resourceCmd);
const creds: ResolvedCredentials =
getResolvedCredentials(parentOpts);
.action(async (id: string, options: { data: string; output?: string }) => {
try {
const data: JSONObject = parseJsonArg(options.data);
const parentOpts: CLIOptions = getParentOptions(resourceCmd);
const creds: ResolvedCredentials = getResolvedCredentials(parentOpts);
const result: JSONValue = await executeApiRequest({
apiUrl: creds.apiUrl,
apiKey: creds.apiKey,
apiPath: resource.apiPath,
operation: "update" as ApiOperation,
id,
data,
});
const result: JSONValue = await executeApiRequest({
apiUrl: creds.apiUrl,
apiKey: creds.apiKey,
apiPath: resource.apiPath,
operation: "update" as ApiOperation,
id,
data,
});
console.log(formatOutput(result, options.output));
} catch (error) {
handleError(error);
}
},
);
// eslint-disable-next-line no-console
console.log(formatOutput(result, options.output));
} catch (error) {
handleError(error);
}
});
}
function registerDeleteCommand(
@@ -290,8 +271,7 @@ function registerDeleteCommand(
.action(async (id: string, _options: { force?: boolean }) => {
try {
const parentOpts: CLIOptions = getParentOptions(resourceCmd);
const creds: ResolvedCredentials =
getResolvedCredentials(parentOpts);
const creds: ResolvedCredentials = getResolvedCredentials(parentOpts);
await executeApiRequest({
apiUrl: creds.apiUrl,
@@ -301,9 +281,7 @@ function registerDeleteCommand(
id,
});
printSuccess(
`${resource.singularName} ${id} deleted successfully.`,
);
printSuccess(`${resource.singularName} ${id} deleted successfully.`);
} catch (error) {
handleError(error);
}
@@ -321,17 +299,14 @@ function registerCountCommand(
.action(async (options: { query?: string }) => {
try {
const parentOpts: CLIOptions = getParentOptions(resourceCmd);
const creds: ResolvedCredentials =
getResolvedCredentials(parentOpts);
const creds: ResolvedCredentials = getResolvedCredentials(parentOpts);
const result: JSONValue = await executeApiRequest({
apiUrl: creds.apiUrl,
apiKey: creds.apiKey,
apiPath: resource.apiPath,
operation: "count" as ApiOperation,
query: options.query
? parseJsonArg(options.query)
: undefined,
query: options.query ? parseJsonArg(options.query) : undefined,
});
// Count response is typically { count: number }
@@ -341,8 +316,10 @@ function registerCountCommand(
!Array.isArray(result) &&
"count" in (result as JSONObject)
) {
// eslint-disable-next-line no-console
console.log((result as JSONObject)["count"]);
} else {
// eslint-disable-next-line no-console
console.log(result);
}
} catch (error) {
@@ -357,9 +334,7 @@ export function registerResourceCommands(program: Command): void {
for (const resource of resources) {
const resourceCmd: Command = program
.command(resource.name)
.description(
`Manage ${resource.pluralName} (${resource.modelType})`,
);
.description(`Manage ${resource.pluralName} (${resource.modelType})`);
// Database models get full CRUD
if (resource.modelType === "database") {

View File

@@ -1,10 +1,16 @@
import { Command } from "commander";
import { CLIContext } from "../Types/CLITypes";
import { getCurrentContext, CLIOptions, getResolvedCredentials } from "../Core/ConfigManager";
import { ResolvedCredentials } from "../Types/CLITypes";
import {
CLIContext,
ResolvedCredentials,
ResourceInfo,
} from "../Types/CLITypes";
import {
getCurrentContext,
CLIOptions,
getResolvedCredentials,
} from "../Core/ConfigManager";
import { printInfo, printError } from "../Core/OutputFormatter";
import { discoverResources } from "./ResourceCommands";
import { ResourceInfo } from "../Types/CLITypes";
import Table from "cli-table3";
import chalk from "chalk";
@@ -15,11 +21,15 @@ export function registerUtilityCommands(program: Command): void {
.description("Print CLI version")
.action(() => {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg: { version: string } = require("../package.json") as { version: string };
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
const pkg: { version: string } = require("../package.json") as {
version: string;
};
// eslint-disable-next-line no-console
console.log(pkg.version);
} catch {
// Fallback if package.json can't be loaded at runtime
// eslint-disable-next-line no-console
console.log("1.0.0");
}
});
@@ -42,7 +52,9 @@ export function registerUtilityCommands(program: Command): void {
try {
creds = getResolvedCredentials(cliOpts);
} catch {
printInfo("Not authenticated. Run `oneuptime login` to authenticate.");
printInfo(
"Not authenticated. Run `oneuptime login` to authenticate.",
);
return;
}
@@ -53,9 +65,12 @@ export function registerUtilityCommands(program: Command): void {
creds.apiKey.substring(creds.apiKey.length - 4)
: "****";
// eslint-disable-next-line no-console
console.log(`URL: ${creds.apiUrl}`);
// eslint-disable-next-line no-console
console.log(`API Key: ${maskedKey}`);
if (ctx) {
// eslint-disable-next-line no-console
console.log(`Context: ${ctx.name}`);
}
} catch (error) {
@@ -73,7 +88,9 @@ export function registerUtilityCommands(program: Command): void {
const resources: ResourceInfo[] = discoverResources();
const filtered: ResourceInfo[] = options.type
? resources.filter((r: ResourceInfo) => r.modelType === options.type)
? resources.filter((r: ResourceInfo) => {
return r.modelType === options.type;
})
: resources;
if (filtered.length === 0) {
@@ -87,16 +104,26 @@ export function registerUtilityCommands(program: Command): void {
const table: Table.Table = new Table({
head: ["Command", "Singular", "Plural", "Type", "API Path"].map(
(h: string) => (noColor ? h : chalk.cyan(h)),
(h: string) => {
return noColor ? h : chalk.cyan(h);
},
),
style: { head: [], border: [] },
});
for (const r of filtered) {
table.push([r.name, r.singularName, r.pluralName, r.modelType, r.apiPath]);
table.push([
r.name,
r.singularName,
r.pluralName,
r.modelType,
r.apiPath,
]);
}
// eslint-disable-next-line no-console
console.log(table.toString());
// eslint-disable-next-line no-console
console.log(`\nTotal: ${filtered.length} resources`);
});
}

View File

@@ -126,9 +126,7 @@ export async function executeApiRequest(
case "count":
case "list":
case "read":
response = await API.post(
data ? { ...baseOptions, data } : baseOptions,
);
response = await API.post(data ? { ...baseOptions, data } : baseOptions);
break;
case "update":
response = await API.put(data ? { ...baseOptions, data } : baseOptions);

View File

@@ -80,10 +80,12 @@ export function removeContext(name: string): void {
export function listContexts(): Array<CLIContext & { isCurrent: boolean }> {
const config: CLIConfig = load();
return Object.values(config.contexts).map(
(ctx: CLIContext): CLIContext & { isCurrent: boolean } => ({
...ctx,
isCurrent: ctx.name === config.currentContext,
}),
(ctx: CLIContext): CLIContext & { isCurrent: boolean } => {
return {
...ctx,
isCurrent: ctx.name === config.currentContext,
};
},
);
}

View File

@@ -5,8 +5,7 @@ import chalk from "chalk";
function isColorDisabled(): boolean {
return (
process.env["NO_COLOR"] !== undefined ||
process.argv.includes("--no-color")
process.env["NO_COLOR"] !== undefined || process.argv.includes("--no-color")
);
}
@@ -69,21 +68,21 @@ function formatTable(data: JSONValue, wide: boolean): string {
"createdAt",
"updatedAt",
];
const prioritized: string[] = priority.filter((col: string) =>
columns.includes(col),
);
const remaining: string[] = columns.filter(
(col: string) => !priority.includes(col),
);
const prioritized: string[] = priority.filter((col: string) => {
return columns.includes(col);
});
const remaining: string[] = columns.filter((col: string) => {
return !priority.includes(col);
});
columns = [...prioritized, ...remaining].slice(0, 6);
}
const useColor: boolean = !isColorDisabled();
const table: Table.Table = new Table({
head: columns.map((col: string) =>
useColor ? chalk.cyan(col) : col,
),
head: columns.map((col: string) => {
return useColor ? chalk.cyan(col) : col;
}),
style: {
head: [],
border: [],
@@ -154,8 +153,10 @@ export function formatOutput(data: JSONValue, format?: string): string {
export function printSuccess(message: string): void {
const useColor: boolean = !isColorDisabled();
if (useColor) {
// eslint-disable-next-line no-console
console.log(chalk.green(message));
} else {
// eslint-disable-next-line no-console
console.log(message);
}
}
@@ -163,8 +164,10 @@ export function printSuccess(message: string): void {
export function printError(message: string): void {
const useColor: boolean = !isColorDisabled();
if (useColor) {
// eslint-disable-next-line no-console
console.error(chalk.red(message));
} else {
// eslint-disable-next-line no-console
console.error(message);
}
}
@@ -172,8 +175,10 @@ export function printError(message: string): void {
export function printWarning(message: string): void {
const useColor: boolean = !isColorDisabled();
if (useColor) {
// eslint-disable-next-line no-console
console.error(chalk.yellow(message));
} else {
// eslint-disable-next-line no-console
console.error(message);
}
}
@@ -181,8 +186,10 @@ export function printWarning(message: string): void {
export function printInfo(message: string): void {
const useColor: boolean = !isColorDisabled();
if (useColor) {
// eslint-disable-next-line no-console
console.log(chalk.blue(message));
} else {
// eslint-disable-next-line no-console
console.log(message);
}
}

View File

@@ -9,7 +9,9 @@ const program: Command = new Command();
program
.name("oneuptime")
.description("OneUptime CLI - Manage your OneUptime resources from the command line")
.description(
"OneUptime CLI - Manage your OneUptime resources from the command line",
)
.version("1.0.0")
.option("--api-key <key>", "API key (overrides config)")
.option("--url <url>", "OneUptime instance URL (overrides config)")

View File

@@ -1,14 +1,20 @@
import { executeApiRequest, ApiRequestOptions } from "../Core/ApiClient";
import API from "Common/Utils/API";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import { JSONValue } from "Common/Types/JSON";
// Mock the Common/Utils/API module
jest.mock("Common/Utils/API", () => {
const mockPost = jest.fn();
const mockPut = jest.fn();
const mockDelete = jest.fn();
const mockPost: jest.Mock = jest.fn();
const mockPut: jest.Mock = jest.fn();
const mockDelete: jest.Mock = jest.fn();
function MockAPI(protocol, hostname, _route) {
function MockAPI(
this: { protocol: string; hostname: string },
protocol: string,
hostname: string,
_route: string,
): void {
this.protocol = protocol;
this.hostname = hostname;
}
@@ -23,36 +29,50 @@ jest.mock("Common/Utils/API", () => {
};
});
function createSuccessResponse(data) {
function createSuccessResponse(
data: Record<string, unknown> | Record<string, unknown>[],
): {
data: Record<string, unknown> | Record<string, unknown>[];
statusCode: number;
} {
return { data, statusCode: 200 };
}
function createErrorResponse(statusCode, message) {
// HTTPErrorResponse computes `message` from `.data` via a getter.
// We create a proper prototype chain and set data to contain the message.
const resp = Object.create(HTTPErrorResponse.prototype);
function createErrorResponse(
statusCode: number,
message: string,
): HTTPErrorResponse {
/*
* HTTPErrorResponse computes `message` from `.data` via a getter.
* We create a proper prototype chain and set data to contain the message.
*/
const resp: HTTPErrorResponse = Object.create(HTTPErrorResponse.prototype);
resp.statusCode = statusCode;
// HTTPResponse stores data in _jsonData and exposes it via `data` getter
// But since the prototype chain may not have full getters, we define them
/*
* HTTPResponse stores data in _jsonData and exposes it via `data` getter
* But since the prototype chain may not have full getters, we define them
*/
Object.defineProperty(resp, "data", {
get: () => ({ message: message }),
get: (): { message: string } => {
return { message: message };
},
configurable: true,
});
return resp;
}
describe("ApiClient", () => {
let mockPost;
let mockPut;
let mockDelete;
let mockPost: jest.Mock;
let mockPut: jest.Mock;
let mockDelete: jest.Mock;
beforeEach(() => {
mockPost = API.post;
mockPut = API.put;
mockDelete = API.delete;
(mockPost as any).mockReset();
(mockPut as any).mockReset();
(mockDelete as any).mockReset();
mockPost = API.post as jest.Mock;
mockPut = API.put as jest.Mock;
mockDelete = API.delete as jest.Mock;
(mockPost as jest.Mock).mockReset();
(mockPut as jest.Mock).mockReset();
(mockDelete as jest.Mock).mockReset();
});
const baseOptions: ApiRequestOptions = {
@@ -64,40 +84,46 @@ describe("ApiClient", () => {
describe("create operation", () => {
it("should make a POST request with data wrapped in { data: ... }", async () => {
(mockPost as any).mockResolvedValue(createSuccessResponse({ _id: "123" }));
(mockPost as jest.Mock).mockResolvedValue(
createSuccessResponse({ _id: "123" }),
);
const result = await executeApiRequest({
const result: JSONValue = await executeApiRequest({
...baseOptions,
operation: "create",
data: { name: "Test Incident" },
});
expect(mockPost).toHaveBeenCalledTimes(1);
const callArgs = (mockPost as any).mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs: any = (mockPost as jest.Mock).mock.calls[0][0];
expect(callArgs.data).toEqual({ data: { name: "Test Incident" } });
expect(result).toEqual({ _id: "123" });
});
it("should use empty object when no data provided for create", async () => {
(mockPost as any).mockResolvedValue(createSuccessResponse({ _id: "123" }));
(mockPost as jest.Mock).mockResolvedValue(
createSuccessResponse({ _id: "123" }),
);
await executeApiRequest({
...baseOptions,
operation: "create",
});
const callArgs = (mockPost as any).mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs: any = (mockPost as jest.Mock).mock.calls[0][0];
expect(callArgs.data).toEqual({ data: {} });
});
});
describe("read operation", () => {
it("should make a POST request with select and id in route", async () => {
(mockPost as any).mockResolvedValue(
(mockPost as jest.Mock).mockResolvedValue(
createSuccessResponse({ _id: "abc", name: "Test" }),
);
const result = await executeApiRequest({
const result: JSONValue = await executeApiRequest({
...baseOptions,
operation: "read",
id: "abc-123",
@@ -105,14 +131,15 @@ describe("ApiClient", () => {
});
expect(mockPost).toHaveBeenCalledTimes(1);
const callArgs = (mockPost as any).mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs: any = (mockPost as jest.Mock).mock.calls[0][0];
expect(callArgs.url.toString()).toContain("abc-123/get-item");
expect(callArgs.data).toEqual({ select: { _id: true, name: true } });
expect(result).toEqual({ _id: "abc", name: "Test" });
});
it("should use empty select when none provided", async () => {
(mockPost as any).mockResolvedValue(createSuccessResponse({}));
(mockPost as jest.Mock).mockResolvedValue(createSuccessResponse({}));
await executeApiRequest({
...baseOptions,
@@ -120,19 +147,21 @@ describe("ApiClient", () => {
id: "abc-123",
});
const callArgs = (mockPost as any).mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs: any = (mockPost as jest.Mock).mock.calls[0][0];
expect(callArgs.data).toEqual({ select: {} });
});
it("should build route without id when no id provided", async () => {
(mockPost as any).mockResolvedValue(createSuccessResponse({}));
(mockPost as jest.Mock).mockResolvedValue(createSuccessResponse({}));
await executeApiRequest({
...baseOptions,
operation: "read",
});
const callArgs = (mockPost as any).mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs: any = (mockPost as jest.Mock).mock.calls[0][0];
expect(callArgs.url.toString()).toContain("/api/incident");
expect(callArgs.url.toString()).not.toContain("/get-item");
});
@@ -140,7 +169,9 @@ describe("ApiClient", () => {
describe("list operation", () => {
it("should make a POST request with query, select, skip, limit, sort", async () => {
(mockPost as any).mockResolvedValue(createSuccessResponse({ data: [] }));
(mockPost as jest.Mock).mockResolvedValue(
createSuccessResponse({ data: [] }),
);
await executeApiRequest({
...baseOptions,
@@ -153,7 +184,8 @@ describe("ApiClient", () => {
});
expect(mockPost).toHaveBeenCalledTimes(1);
const callArgs = (mockPost as any).mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs: any = (mockPost as jest.Mock).mock.calls[0][0];
expect(callArgs.url.toString()).toContain("/get-list");
expect(callArgs.data).toEqual({
query: { status: "active" },
@@ -165,14 +197,17 @@ describe("ApiClient", () => {
});
it("should use defaults when no query options provided", async () => {
(mockPost as any).mockResolvedValue(createSuccessResponse({ data: [] }));
(mockPost as jest.Mock).mockResolvedValue(
createSuccessResponse({ data: [] }),
);
await executeApiRequest({
...baseOptions,
operation: "list",
});
const callArgs = (mockPost as any).mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs: any = (mockPost as jest.Mock).mock.calls[0][0];
expect(callArgs.data).toEqual({
query: {},
select: {},
@@ -185,16 +220,19 @@ describe("ApiClient", () => {
describe("count operation", () => {
it("should make a POST request to /count path", async () => {
(mockPost as any).mockResolvedValue(createSuccessResponse({ count: 42 }));
(mockPost as jest.Mock).mockResolvedValue(
createSuccessResponse({ count: 42 }),
);
const result = await executeApiRequest({
const result: JSONValue = await executeApiRequest({
...baseOptions,
operation: "count",
query: { status: "active" },
});
expect(mockPost).toHaveBeenCalledTimes(1);
const callArgs = (mockPost as any).mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs: any = (mockPost as jest.Mock).mock.calls[0][0];
expect(callArgs.url.toString()).toContain("/count");
expect(result).toEqual({ count: 42 });
});
@@ -202,9 +240,11 @@ describe("ApiClient", () => {
describe("update operation", () => {
it("should make a PUT request with data", async () => {
(mockPut as any).mockResolvedValue(createSuccessResponse({ _id: "abc" }));
(mockPut as jest.Mock).mockResolvedValue(
createSuccessResponse({ _id: "abc" }),
);
const result = await executeApiRequest({
const result: JSONValue = await executeApiRequest({
...baseOptions,
operation: "update",
id: "abc-123",
@@ -212,14 +252,15 @@ describe("ApiClient", () => {
});
expect(mockPut).toHaveBeenCalledTimes(1);
const callArgs = (mockPut as any).mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs: any = (mockPut as jest.Mock).mock.calls[0][0];
expect(callArgs.url.toString()).toContain("abc-123");
expect(callArgs.data).toEqual({ data: { name: "Updated" } });
expect(result).toEqual({ _id: "abc" });
});
it("should use empty object when no data provided for update", async () => {
(mockPut as any).mockResolvedValue(createSuccessResponse({}));
(mockPut as jest.Mock).mockResolvedValue(createSuccessResponse({}));
await executeApiRequest({
...baseOptions,
@@ -227,26 +268,28 @@ describe("ApiClient", () => {
id: "abc-123",
});
const callArgs = (mockPut as any).mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs: any = (mockPut as jest.Mock).mock.calls[0][0];
expect(callArgs.data).toEqual({ data: {} });
});
it("should build route without id when no id provided", async () => {
(mockPut as any).mockResolvedValue(createSuccessResponse({}));
(mockPut as jest.Mock).mockResolvedValue(createSuccessResponse({}));
await executeApiRequest({
...baseOptions,
operation: "update",
});
const callArgs = (mockPut as any).mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs: any = (mockPut as jest.Mock).mock.calls[0][0];
expect(callArgs.url.toString()).toContain("/api/incident");
});
});
describe("delete operation", () => {
it("should make a DELETE request", async () => {
(mockDelete as any).mockResolvedValue(createSuccessResponse({}));
(mockDelete as jest.Mock).mockResolvedValue(createSuccessResponse({}));
await executeApiRequest({
...baseOptions,
@@ -255,27 +298,31 @@ describe("ApiClient", () => {
});
expect(mockDelete).toHaveBeenCalledTimes(1);
const callArgs = (mockDelete as any).mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs: any = (mockDelete as jest.Mock).mock.calls[0][0];
expect(callArgs.url.toString()).toContain("abc-123");
expect(callArgs.data).toBeUndefined();
});
it("should build route without id when no id provided", async () => {
(mockDelete as any).mockResolvedValue(createSuccessResponse({}));
(mockDelete as jest.Mock).mockResolvedValue(createSuccessResponse({}));
await executeApiRequest({
...baseOptions,
operation: "delete",
});
const callArgs = (mockDelete as any).mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs: any = (mockDelete as jest.Mock).mock.calls[0][0];
expect(callArgs.url.toString()).toContain("/api/incident");
});
});
describe("error handling", () => {
it("should throw on HTTPErrorResponse", async () => {
(mockPost as any).mockResolvedValue(createErrorResponse(500, "Server Error"));
(mockPost as jest.Mock).mockResolvedValue(
createErrorResponse(500, "Server Error"),
);
await expect(
executeApiRequest({ ...baseOptions, operation: "create", data: {} }),
@@ -283,7 +330,9 @@ describe("ApiClient", () => {
});
it("should include status code in error message", async () => {
(mockPost as any).mockResolvedValue(createErrorResponse(403, "Forbidden"));
(mockPost as jest.Mock).mockResolvedValue(
createErrorResponse(403, "Forbidden"),
);
await expect(
executeApiRequest({ ...baseOptions, operation: "list" }),
@@ -291,7 +340,7 @@ describe("ApiClient", () => {
});
it("should handle error response with no message", async () => {
(mockPost as any).mockResolvedValue(createErrorResponse(500, ""));
(mockPost as jest.Mock).mockResolvedValue(createErrorResponse(500, ""));
await expect(
executeApiRequest({ ...baseOptions, operation: "list" }),
@@ -301,7 +350,7 @@ describe("ApiClient", () => {
describe("headers", () => {
it("should include APIKey, Content-Type, and Accept headers", async () => {
(mockPost as any).mockResolvedValue(createSuccessResponse({}));
(mockPost as jest.Mock).mockResolvedValue(createSuccessResponse({}));
await executeApiRequest({
...baseOptions,
@@ -309,7 +358,8 @@ describe("ApiClient", () => {
data: { name: "Test" },
});
const callArgs = (mockPost as any).mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs: any = (mockPost as jest.Mock).mock.calls[0][0];
expect(callArgs.headers["APIKey"]).toBe("test-api-key");
expect(callArgs.headers["Content-Type"]).toBe("application/json");
expect(callArgs.headers["Accept"]).toBe("application/json");
@@ -319,7 +369,7 @@ describe("ApiClient", () => {
describe("default/unknown operation", () => {
it("should handle unknown operation in buildRequestData (falls to default)", async () => {
// The "delete" case hits the default branch in buildRequestData returning undefined
(mockDelete as any).mockResolvedValue(createSuccessResponse({}));
(mockDelete as jest.Mock).mockResolvedValue(createSuccessResponse({}));
await executeApiRequest({
...baseOptions,
@@ -328,7 +378,8 @@ describe("ApiClient", () => {
});
// Should not send data for delete
const callArgs = (mockDelete as any).mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs: any = (mockDelete as jest.Mock).mock.calls[0][0];
expect(callArgs.data).toBeUndefined();
});
});

View File

@@ -5,8 +5,8 @@ import * as fs from "fs";
import * as path from "path";
import * as os from "os";
const CONFIG_DIR = path.join(os.homedir(), ".oneuptime");
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
const CONFIG_DIR: string = path.join(os.homedir(), ".oneuptime");
const CONFIG_FILE: string = path.join(CONFIG_DIR, "config.json");
describe("ConfigCommands", () => {
let originalConfigContent: string | null = null;
@@ -42,7 +42,7 @@ describe("ConfigCommands", () => {
});
function createProgram(): Command {
const program = new Command();
const program: Command = new Command();
program.exitOverride(); // Prevent commander from calling process.exit
program.configureOutput({
writeOut: () => {},
@@ -54,7 +54,7 @@ describe("ConfigCommands", () => {
describe("login command", () => {
it("should create a context and set it as current", async () => {
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync([
"node",
"test",
@@ -63,7 +63,8 @@ describe("ConfigCommands", () => {
"https://example.com",
]);
const ctx = ConfigManager.getCurrentContext();
const ctx: ReturnType<typeof ConfigManager.getCurrentContext> =
ConfigManager.getCurrentContext();
expect(ctx).not.toBeNull();
expect(ctx!.name).toBe("default");
expect(ctx!.apiUrl).toBe("https://example.com");
@@ -71,7 +72,7 @@ describe("ConfigCommands", () => {
});
it("should use custom context name", async () => {
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync([
"node",
"test",
@@ -82,19 +83,20 @@ describe("ConfigCommands", () => {
"production",
]);
const ctx = ConfigManager.getCurrentContext();
const ctx: ReturnType<typeof ConfigManager.getCurrentContext> =
ConfigManager.getCurrentContext();
expect(ctx!.name).toBe("production");
});
it("should handle login errors gracefully", async () => {
// Mock addContext to throw
const addCtxSpy = jest
const addCtxSpy: jest.SpyInstance = jest
.spyOn(ConfigManager, "addContext")
.mockImplementation(() => {
throw new Error("Permission denied");
});
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync([
"node",
"test",
@@ -108,7 +110,7 @@ describe("ConfigCommands", () => {
});
it("should strip trailing slashes from URL", async () => {
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync([
"node",
"test",
@@ -117,14 +119,15 @@ describe("ConfigCommands", () => {
"https://example.com///",
]);
const ctx = ConfigManager.getCurrentContext();
const ctx: ReturnType<typeof ConfigManager.getCurrentContext> =
ConfigManager.getCurrentContext();
expect(ctx!.apiUrl).toBe("https://example.com");
});
});
describe("context list command", () => {
it("should show message when no contexts exist", async () => {
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync(["node", "test", "context", "list"]);
expect(consoleLogSpy).toHaveBeenCalled();
});
@@ -141,7 +144,7 @@ describe("ConfigCommands", () => {
apiKey: "k2",
});
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync(["node", "test", "context", "list"]);
expect(consoleLogSpy).toHaveBeenCalled();
});
@@ -160,15 +163,16 @@ describe("ConfigCommands", () => {
apiKey: "k2",
});
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync(["node", "test", "context", "use", "b"]);
const current = ConfigManager.getCurrentContext();
const current: ReturnType<typeof ConfigManager.getCurrentContext> =
ConfigManager.getCurrentContext();
expect(current!.name).toBe("b");
});
it("should handle non-existent context", async () => {
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync(["node", "test", "context", "use", "nope"]);
expect(exitSpy).toHaveBeenCalledWith(1);
@@ -183,7 +187,7 @@ describe("ConfigCommands", () => {
apiKey: "abcdefghijklm",
});
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync(["node", "test", "context", "current"]);
// Check that masked key is shown
@@ -196,7 +200,7 @@ describe("ConfigCommands", () => {
});
it("should show message when no current context", async () => {
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync(["node", "test", "context", "current"]);
expect(consoleLogSpy).toHaveBeenCalled();
});
@@ -208,7 +212,7 @@ describe("ConfigCommands", () => {
apiKey: "abc",
});
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync(["node", "test", "context", "current"]);
expect(consoleLogSpy).toHaveBeenCalledWith("API Key: ****");
@@ -223,7 +227,7 @@ describe("ConfigCommands", () => {
apiKey: "k1",
});
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync([
"node",
"test",
@@ -232,12 +236,13 @@ describe("ConfigCommands", () => {
"todelete",
]);
const contexts = ConfigManager.listContexts();
const contexts: ReturnType<typeof ConfigManager.listContexts> =
ConfigManager.listContexts();
expect(contexts).toHaveLength(0);
});
it("should handle deletion of non-existent context", async () => {
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync([
"node",
"test",

View File

@@ -2,10 +2,10 @@ import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import * as ConfigManager from "../Core/ConfigManager";
import { CLIContext, CLIConfig, ResolvedCredentials } from "../Types/CLITypes";
import { CLIConfig, ResolvedCredentials } from "../Types/CLITypes";
const CONFIG_DIR = path.join(os.homedir(), ".oneuptime");
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
const CONFIG_DIR: string = path.join(os.homedir(), ".oneuptime");
const CONFIG_FILE: string = path.join(CONFIG_DIR, "config.json");
describe("ConfigManager", () => {
let originalConfigContent: string | null = null;
@@ -39,7 +39,7 @@ describe("ConfigManager", () => {
describe("load", () => {
it("should return default config when no config file exists", () => {
const config = ConfigManager.load();
const config: CLIConfig = ConfigManager.load();
expect(config.currentContext).toBe("");
expect(config.contexts).toEqual({});
expect(config.defaults.output).toBe("table");
@@ -61,7 +61,7 @@ describe("ConfigManager", () => {
mode: 0o600,
});
const config = ConfigManager.load();
const config: CLIConfig = ConfigManager.load();
expect(config.currentContext).toBe("test");
expect(config.contexts["test"]?.apiKey).toBe("key123");
});
@@ -72,7 +72,7 @@ describe("ConfigManager", () => {
}
fs.writeFileSync(CONFIG_FILE, "not valid json {{{", { mode: 0o600 });
const config = ConfigManager.load();
const config: CLIConfig = ConfigManager.load();
expect(config.currentContext).toBe("");
expect(config.contexts).toEqual({});
});
@@ -81,9 +81,14 @@ describe("ConfigManager", () => {
describe("save", () => {
it("should create config directory if it does not exist", () => {
// Remove the dir if it exists (we'll restore after)
const tmpDir = path.join(os.tmpdir(), ".oneuptime-test-" + Date.now());
// We can't easily test this with the real path, but we verify save works
// when the dir already exists (which it does after beforeAll).
const tmpDir: string = path.join(
os.tmpdir(),
".oneuptime-test-" + Date.now(),
);
/*
* We can't easily test this with the real path, but we verify save works
* when the dir already exists (which it does after beforeAll).
*/
const config: CLIConfig = {
currentContext: "",
contexts: {},
@@ -103,8 +108,8 @@ describe("ConfigManager", () => {
defaults: { output: "table", limit: 10 },
};
ConfigManager.save(config);
const content = fs.readFileSync(CONFIG_FILE, "utf-8");
const parsed = JSON.parse(content);
const content: string = fs.readFileSync(CONFIG_FILE, "utf-8");
const parsed: CLIConfig = JSON.parse(content);
expect(parsed.currentContext).toBe("x");
});
});
@@ -131,7 +136,8 @@ describe("ConfigManager", () => {
apiUrl: "https://prod.com",
apiKey: "k1",
});
const ctx = ConfigManager.getCurrentContext();
const ctx: ReturnType<typeof ConfigManager.getCurrentContext> =
ConfigManager.getCurrentContext();
expect(ctx).not.toBeNull();
expect(ctx!.name).toBe("prod");
});
@@ -145,7 +151,8 @@ describe("ConfigManager", () => {
apiKey: "sk-prod-123",
});
const current = ConfigManager.getCurrentContext();
const current: ReturnType<typeof ConfigManager.getCurrentContext> =
ConfigManager.getCurrentContext();
expect(current).not.toBeNull();
expect(current!.name).toBe("prod");
});
@@ -162,7 +169,8 @@ describe("ConfigManager", () => {
apiKey: "key2",
});
const current = ConfigManager.getCurrentContext();
const current: ReturnType<typeof ConfigManager.getCurrentContext> =
ConfigManager.getCurrentContext();
expect(current!.name).toBe("prod"); // First one remains current
});
@@ -178,7 +186,8 @@ describe("ConfigManager", () => {
apiKey: "key2",
});
const contexts = ConfigManager.listContexts();
const contexts: ReturnType<typeof ConfigManager.listContexts> =
ConfigManager.listContexts();
expect(contexts).toHaveLength(2);
});
});
@@ -197,14 +206,15 @@ describe("ConfigManager", () => {
});
ConfigManager.setCurrentContext("b");
const current = ConfigManager.getCurrentContext();
const current: ReturnType<typeof ConfigManager.getCurrentContext> =
ConfigManager.getCurrentContext();
expect(current!.name).toBe("b");
});
it("should throw for non-existent context", () => {
expect(() => ConfigManager.setCurrentContext("nonexistent")).toThrow(
'Context "nonexistent" does not exist',
);
expect(() => {
return ConfigManager.setCurrentContext("nonexistent");
}).toThrow('Context "nonexistent" does not exist');
});
});
@@ -217,14 +227,15 @@ describe("ConfigManager", () => {
});
ConfigManager.removeContext("test");
const contexts = ConfigManager.listContexts();
const contexts: ReturnType<typeof ConfigManager.listContexts> =
ConfigManager.listContexts();
expect(contexts).toHaveLength(0);
});
it("should throw for non-existent context", () => {
expect(() => ConfigManager.removeContext("nonexistent")).toThrow(
'Context "nonexistent" does not exist',
);
expect(() => {
return ConfigManager.removeContext("nonexistent");
}).toThrow('Context "nonexistent" does not exist');
});
it("should update current context when removing the current one", () => {
@@ -241,7 +252,8 @@ describe("ConfigManager", () => {
ConfigManager.setCurrentContext("a");
ConfigManager.removeContext("a");
const current = ConfigManager.getCurrentContext();
const current: ReturnType<typeof ConfigManager.getCurrentContext> =
ConfigManager.getCurrentContext();
expect(current).not.toBeNull();
expect(current!.name).toBe("b");
});
@@ -255,7 +267,7 @@ describe("ConfigManager", () => {
ConfigManager.removeContext("only");
expect(ConfigManager.getCurrentContext()).toBeNull();
const config = ConfigManager.load();
const config: CLIConfig = ConfigManager.load();
expect(config.currentContext).toBe("");
});
@@ -273,7 +285,8 @@ describe("ConfigManager", () => {
ConfigManager.setCurrentContext("a");
ConfigManager.removeContext("b");
const current = ConfigManager.getCurrentContext();
const current: ReturnType<typeof ConfigManager.getCurrentContext> =
ConfigManager.getCurrentContext();
expect(current!.name).toBe("a");
});
});
@@ -296,9 +309,22 @@ describe("ConfigManager", () => {
});
ConfigManager.setCurrentContext("b");
const contexts = ConfigManager.listContexts();
const a = contexts.find((c) => c.name === "a");
const b = contexts.find((c) => c.name === "b");
const contexts: ReturnType<typeof ConfigManager.listContexts> =
ConfigManager.listContexts();
const a:
| ReturnType<typeof ConfigManager.listContexts>[number]
| undefined = contexts.find(
(c: ReturnType<typeof ConfigManager.listContexts>[number]) => {
return c.name === "a";
},
);
const b:
| ReturnType<typeof ConfigManager.listContexts>[number]
| undefined = contexts.find(
(c: ReturnType<typeof ConfigManager.listContexts>[number]) => {
return c.name === "b";
},
);
expect(a!.isCurrent).toBe(false);
expect(b!.isCurrent).toBe(true);
});
@@ -340,9 +366,9 @@ describe("ConfigManager", () => {
});
it("should throw when --context flag references non-existent context", () => {
expect(() =>
ConfigManager.getResolvedCredentials({ context: "nope" }),
).toThrow('Context "nope" does not exist');
expect(() => {
return ConfigManager.getResolvedCredentials({ context: "nope" });
}).toThrow('Context "nope" does not exist');
});
it("should resolve from current context in config", () => {
@@ -390,17 +416,19 @@ describe("ConfigManager", () => {
const creds: ResolvedCredentials = ConfigManager.getResolvedCredentials(
{},
);
// env vars take priority: both are set so goes through priority 2
// Actually, only ONEUPTIME_API_KEY is set, not ONEUPTIME_URL
// So it falls through to priority 4 (current context)
/*
* env vars take priority: both are set so goes through priority 2
* Actually, only ONEUPTIME_API_KEY is set, not ONEUPTIME_URL
* So it falls through to priority 4 (current context)
*/
expect(creds.apiKey).toBe("ctx-key");
expect(creds.apiUrl).toBe("https://ctx.com");
});
it("should throw when no credentials available at all", () => {
expect(() => ConfigManager.getResolvedCredentials({})).toThrow(
"No credentials found",
);
expect(() => {
return ConfigManager.getResolvedCredentials({});
}).toThrow("No credentials found");
});
it("should prefer CLI flags over env vars", () => {

View File

@@ -9,9 +9,11 @@ describe("ErrorHandler", () => {
exitSpy = jest.spyOn(process, "exit").mockImplementation((() => {
// no-op
}) as any);
printErrorSpy = jest.spyOn(OutputFormatter, "printError").mockImplementation(() => {
// no-op
});
printErrorSpy = jest
.spyOn(OutputFormatter, "printError")
.mockImplementation(() => {
// no-op
});
});
afterEach(() => {
@@ -44,9 +46,7 @@ describe("ErrorHandler", () => {
it("should exit with NotFound for 404 errors", () => {
handleError(new Error("HTTP 404 response"));
expect(printErrorSpy).toHaveBeenCalledWith(
"Not found: HTTP 404 response",
);
expect(printErrorSpy).toHaveBeenCalledWith("Not found: HTTP 404 response");
expect(exitSpy).toHaveBeenCalledWith(ExitCode.NotFound);
});
@@ -65,9 +65,7 @@ describe("ErrorHandler", () => {
it("should exit with GeneralError for generic Error objects", () => {
handleError(new Error("Something went wrong"));
expect(printErrorSpy).toHaveBeenCalledWith(
"Error: Something went wrong",
);
expect(printErrorSpy).toHaveBeenCalledWith("Error: Something went wrong");
expect(exitSpy).toHaveBeenCalledWith(ExitCode.GeneralError);
});

View File

@@ -1,11 +1,11 @@
import { Command } from "commander";
import { Command, Option } from "commander";
import { registerConfigCommands } from "../Commands/ConfigCommands";
import { registerResourceCommands } from "../Commands/ResourceCommands";
import { registerUtilityCommands } from "../Commands/UtilityCommands";
describe("Index (CLI entry point)", () => {
it("should create a program with all command groups registered", () => {
const program = new Command();
const program: Command = new Command();
program
.name("oneuptime")
.description(
@@ -23,7 +23,9 @@ describe("Index (CLI entry point)", () => {
registerResourceCommands(program);
// Verify all expected commands are registered
const commandNames = program.commands.map((c) => c.name());
const commandNames: string[] = program.commands.map((c: Command) => {
return c.name();
});
expect(commandNames).toContain("login");
expect(commandNames).toContain("context");
expect(commandNames).toContain("version");
@@ -35,14 +37,14 @@ describe("Index (CLI entry point)", () => {
});
it("should set correct program name and description", () => {
const program = new Command();
const program: Command = new Command();
program.name("oneuptime").description("OneUptime CLI");
expect(program.name()).toBe("oneuptime");
});
it("should define global options", () => {
const program = new Command();
const program: Command = new Command();
program
.option("--api-key <key>", "API key")
.option("--url <url>", "URL")
@@ -51,8 +53,10 @@ describe("Index (CLI entry point)", () => {
.option("--no-color", "Disable color");
// Parse with just the program name - verify options are registered
const options = program.options;
const optionNames = options.map((o) => o.long || o.short);
const options: readonly Option[] = program.options;
const optionNames: (string | undefined)[] = options.map((o: Option) => {
return o.long || o.short;
});
expect(optionNames).toContain("--api-key");
expect(optionNames).toContain("--url");
expect(optionNames).toContain("--context");

View File

@@ -5,6 +5,7 @@ import {
printWarning,
printInfo,
} from "../Core/OutputFormatter";
import { JSONObject } from "Common/Types/JSON";
describe("OutputFormatter", () => {
let consoleLogSpy: jest.SpyInstance;
@@ -32,48 +33,48 @@ describe("OutputFormatter", () => {
describe("formatOutput with JSON format", () => {
it("should format single object as JSON", () => {
const data = { id: "123", name: "Test" };
const result = formatOutput(data, "json");
const data: Record<string, string> = { id: "123", name: "Test" };
const result: string = formatOutput(data, "json");
expect(JSON.parse(result)).toEqual(data);
});
it("should format array as JSON", () => {
const data = [
const data: Record<string, string>[] = [
{ id: "1", name: "A" },
{ id: "2", name: "B" },
];
const result = formatOutput(data, "json");
const result: string = formatOutput(data, "json");
expect(JSON.parse(result)).toEqual(data);
});
it("should format null as JSON", () => {
const result = formatOutput(null, "json");
const result: string = formatOutput(null, "json");
expect(result).toBe("null");
});
it("should format number as JSON", () => {
const result = formatOutput(42, "json");
const result: string = formatOutput(42, "json");
expect(result).toBe("42");
});
it("should format string as JSON", () => {
const result = formatOutput("hello", "json");
const result: string = formatOutput("hello", "json");
expect(result).toBe('"hello"');
});
it("should format boolean as JSON", () => {
const result = formatOutput(true, "json");
const result: string = formatOutput(true, "json");
expect(result).toBe("true");
});
});
describe("formatOutput with table format", () => {
it("should format array as table", () => {
const data = [
const data: Record<string, string>[] = [
{ _id: "1", name: "A" },
{ _id: "2", name: "B" },
];
const result = formatOutput(data, "table");
const result: string = formatOutput(data, "table");
expect(result).toContain("1");
expect(result).toContain("A");
expect(result).toContain("2");
@@ -81,76 +82,76 @@ describe("OutputFormatter", () => {
});
it("should handle empty array", () => {
const result = formatOutput([], "table");
const result: string = formatOutput([], "table");
expect(result).toBe("No results found.");
});
it("should handle single object as key-value table", () => {
const data = { name: "Test", status: "Active" };
const result = formatOutput(data, "table");
const data: Record<string, string> = { name: "Test", status: "Active" };
const result: string = formatOutput(data, "table");
expect(result).toContain("Test");
expect(result).toContain("Active");
});
it("should return 'No data returned.' for null in table mode", () => {
const result = formatOutput(null, "table");
const result: string = formatOutput(null, "table");
expect(result).toBe("No data returned.");
});
it("should return 'No data returned.' for undefined in table mode", () => {
const result = formatOutput(undefined as any, "table");
const result: string = formatOutput(undefined as any, "table");
expect(result).toBe("No data returned.");
});
it("should return 'No data returned.' for empty string in table mode", () => {
const result = formatOutput("" as any, "table");
const result: string = formatOutput("" as any, "table");
expect(result).toBe("No data returned.");
});
it("should fallback to JSON for array of non-objects", () => {
const data = ["a", "b", "c"];
const result = formatOutput(data, "table");
const data: string[] = ["a", "b", "c"];
const result: string = formatOutput(data, "table");
// First item is not an object, so should fallback to JSON
expect(result).toContain('"a"');
});
it("should truncate long string values", () => {
const longValue = "x".repeat(100);
const data = [{ _id: "1", field: longValue }];
const result = formatOutput(data, "table");
const longValue: string = "x".repeat(100);
const data: Record<string, string>[] = [{ _id: "1", field: longValue }];
const result: string = formatOutput(data, "table");
expect(result).toContain("...");
});
it("should truncate long object values", () => {
const bigObj = { a: "x".repeat(80) };
const data = [{ _id: "1", nested: bigObj }];
const result = formatOutput(data, "table");
const bigObj: Record<string, string> = { a: "x".repeat(80) };
const data: JSONObject[] = [{ _id: "1", nested: bigObj }];
const result: string = formatOutput(data, "table");
expect(result).toContain("...");
});
it("should show short object values without truncation", () => {
const smallObj = { a: 1 };
const data = [{ _id: "1", nested: smallObj }];
const result = formatOutput(data, "table");
const smallObj: Record<string, number> = { a: 1 };
const data: JSONObject[] = [{ _id: "1", nested: smallObj }];
const result: string = formatOutput(data, "table");
expect(result).toContain('{"a":1}');
});
it("should render null values as empty in table", () => {
const data = [{ _id: "1", value: null }];
const result = formatOutput(data, "table");
const data: JSONObject[] = [{ _id: "1", value: null }];
const result: string = formatOutput(data, "table");
expect(result).toContain("1");
});
it("should render undefined values as empty in table", () => {
const data = [{ _id: "1", value: undefined }];
const result = formatOutput(data, "table");
const data: JSONObject[] = [{ _id: "1", value: undefined }];
const result: string = formatOutput(data, "table");
expect(result).toContain("1");
});
});
describe("formatOutput with wide format", () => {
it("should show all columns in wide mode", () => {
const data = [
const data: Record<string, string>[] = [
{
_id: "1",
name: "A",
@@ -163,12 +164,12 @@ describe("OutputFormatter", () => {
col7: "t",
},
];
const result = formatOutput(data, "wide");
const result: string = formatOutput(data, "wide");
expect(result).toContain("col7");
});
it("should limit columns in non-wide table mode", () => {
const data = [
const data: Record<string, string>[] = [
{
_id: "1",
name: "A",
@@ -181,13 +182,13 @@ describe("OutputFormatter", () => {
col7: "t",
},
];
const result = formatOutput(data, "table");
const result: string = formatOutput(data, "table");
// Table mode should limit to 6 columns, so col7 should not appear
expect(result).not.toContain("col7");
});
it("should prioritize common columns in non-wide mode", () => {
const data = [
const data: Record<string, string>[] = [
{
extra1: "a",
extra2: "b",
@@ -203,7 +204,7 @@ describe("OutputFormatter", () => {
updatedAt: "2024-01-02",
},
];
const result = formatOutput(data, "table");
const result: string = formatOutput(data, "table");
// Priority columns should appear
expect(result).toContain("_id");
expect(result).toContain("name");
@@ -212,16 +213,18 @@ describe("OutputFormatter", () => {
describe("format auto-detection", () => {
it("should default to JSON when not a TTY", () => {
const originalIsTTY = process.stdout.isTTY;
const originalIsTTY: boolean | undefined = process.stdout.isTTY;
Object.defineProperty(process.stdout, "isTTY", {
value: false,
writable: true,
configurable: true,
});
const data = { id: "1" };
const result = formatOutput(data);
expect(() => JSON.parse(result)).not.toThrow();
const data: Record<string, string> = { id: "1" };
const result: string = formatOutput(data);
expect(() => {
return JSON.parse(result);
}).not.toThrow();
Object.defineProperty(process.stdout, "isTTY", {
value: originalIsTTY,
@@ -231,17 +234,17 @@ describe("OutputFormatter", () => {
});
it("should default to table when TTY", () => {
const originalIsTTY = process.stdout.isTTY;
const originalIsTTY: boolean | undefined = process.stdout.isTTY;
Object.defineProperty(process.stdout, "isTTY", {
value: true,
writable: true,
configurable: true,
});
const data = [{ _id: "1", name: "Test" }];
const result = formatOutput(data);
const data: Record<string, string>[] = [{ _id: "1", name: "Test" }];
const result: string = formatOutput(data);
// Table format contains box-drawing characters
expect(result).toContain("");
expect(result).toContain("\u2500");
Object.defineProperty(process.stdout, "isTTY", {
value: originalIsTTY,
@@ -251,17 +254,17 @@ describe("OutputFormatter", () => {
});
it("should handle unknown format string and default to table via TTY check", () => {
const data = [{ _id: "1" }];
const data: Record<string, string>[] = [{ _id: "1" }];
// "unknown" is not json/table/wide, so cliFormat falls through and TTY detection occurs
const originalIsTTY = process.stdout.isTTY;
const originalIsTTY: boolean | undefined = process.stdout.isTTY;
Object.defineProperty(process.stdout, "isTTY", {
value: true,
writable: true,
configurable: true,
});
const result = formatOutput(data, "unknown");
expect(result).toContain("");
const result: string = formatOutput(data, "unknown");
expect(result).toContain("\u2500");
Object.defineProperty(process.stdout, "isTTY", {
value: originalIsTTY,
@@ -274,23 +277,26 @@ describe("OutputFormatter", () => {
describe("color handling", () => {
it("should respect NO_COLOR env variable in table rendering", () => {
process.env["NO_COLOR"] = "1";
const data = [{ _id: "1", name: "A" }];
const result = formatOutput(data, "table");
const data: Record<string, string>[] = [{ _id: "1", name: "A" }];
const result: string = formatOutput(data, "table");
// Should not contain ANSI color codes
// eslint-disable-next-line no-control-regex
expect(result).not.toMatch(/\x1b\[/);
});
it("should respect --no-color argv flag in table rendering", () => {
process.argv.push("--no-color");
const data = [{ _id: "1", name: "A" }];
const result = formatOutput(data, "table");
const data: Record<string, string>[] = [{ _id: "1", name: "A" }];
const result: string = formatOutput(data, "table");
// eslint-disable-next-line no-control-regex
expect(result).not.toMatch(/\x1b\[/);
});
it("should render single object without color when NO_COLOR set", () => {
process.env["NO_COLOR"] = "1";
const data = { name: "Test" };
const result = formatOutput(data, "table");
const data: Record<string, string> = { name: "Test" };
const result: string = formatOutput(data, "table");
// eslint-disable-next-line no-control-regex
expect(result).not.toMatch(/\x1b\[/);
expect(result).toContain("name");
});
@@ -300,7 +306,9 @@ describe("OutputFormatter", () => {
it("should log success message with color", () => {
delete process.env["NO_COLOR"];
// Remove --no-color from argv if present
process.argv = process.argv.filter((a) => a !== "--no-color");
process.argv = process.argv.filter((a: string) => {
return a !== "--no-color";
});
printSuccess("OK");
expect(consoleLogSpy).toHaveBeenCalled();
});
@@ -315,7 +323,9 @@ describe("OutputFormatter", () => {
describe("printError", () => {
it("should log error message with color", () => {
delete process.env["NO_COLOR"];
process.argv = process.argv.filter((a) => a !== "--no-color");
process.argv = process.argv.filter((a: string) => {
return a !== "--no-color";
});
printError("fail");
expect(consoleErrorSpy).toHaveBeenCalled();
});
@@ -330,7 +340,9 @@ describe("OutputFormatter", () => {
describe("printWarning", () => {
it("should log warning message with color", () => {
delete process.env["NO_COLOR"];
process.argv = process.argv.filter((a) => a !== "--no-color");
process.argv = process.argv.filter((a: string) => {
return a !== "--no-color";
});
printWarning("warn");
expect(consoleErrorSpy).toHaveBeenCalled();
});
@@ -345,7 +357,9 @@ describe("OutputFormatter", () => {
describe("printInfo", () => {
it("should log info message with color", () => {
delete process.env["NO_COLOR"];
process.argv = process.argv.filter((a) => a !== "--no-color");
process.argv = process.argv.filter((a: string) => {
return a !== "--no-color";
});
printInfo("info");
expect(consoleLogSpy).toHaveBeenCalled();
});

View File

@@ -6,11 +6,15 @@ import * as path from "path";
import * as os from "os";
// Mock the ApiClient module before it's imported by ResourceCommands
const mockExecuteApiRequest = jest.fn();
jest.mock("../Core/ApiClient", () => ({
...jest.requireActual("../Core/ApiClient"),
executeApiRequest: (...args) => mockExecuteApiRequest(...args),
}));
const mockExecuteApiRequest: jest.Mock = jest.fn();
jest.mock("../Core/ApiClient", () => {
return {
...jest.requireActual("../Core/ApiClient"),
executeApiRequest: (...args: unknown[]): unknown => {
return mockExecuteApiRequest(...args);
},
};
});
// Import after mock setup
import {
@@ -18,8 +22,8 @@ import {
registerResourceCommands,
} from "../Commands/ResourceCommands";
const CONFIG_DIR = path.join(os.homedir(), ".oneuptime");
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
const CONFIG_DIR: string = path.join(os.homedir(), ".oneuptime");
const CONFIG_FILE: string = path.join(CONFIG_DIR, "config.json");
describe("ResourceCommands", () => {
let originalConfigContent: string | null = null;
@@ -68,20 +72,32 @@ describe("ResourceCommands", () => {
});
it("should discover the Incident resource", () => {
const incident = resources.find((r) => r.singularName === "Incident");
const incident: ResourceInfo | undefined = resources.find(
(r: ResourceInfo) => {
return r.singularName === "Incident";
},
);
expect(incident).toBeDefined();
expect(incident!.modelType).toBe("database");
expect(incident!.apiPath).toBe("/incident");
});
it("should discover the Monitor resource", () => {
const monitor = resources.find((r) => r.singularName === "Monitor");
const monitor: ResourceInfo | undefined = resources.find(
(r: ResourceInfo) => {
return r.singularName === "Monitor";
},
);
expect(monitor).toBeDefined();
expect(monitor!.modelType).toBe("database");
});
it("should discover the Alert resource", () => {
const alert = resources.find((r) => r.singularName === "Alert");
const alert: ResourceInfo | undefined = resources.find(
(r: ResourceInfo) => {
return r.singularName === "Alert";
},
);
expect(alert).toBeDefined();
});
@@ -107,28 +123,34 @@ describe("ResourceCommands", () => {
describe("registerResourceCommands", () => {
it("should register commands for all discovered resources", () => {
const program = new Command();
const program: Command = new Command();
program.exitOverride();
registerResourceCommands(program);
const resources = discoverResources();
const resources: ResourceInfo[] = discoverResources();
for (const resource of resources) {
const cmd = program.commands.find((c) => c.name() === resource.name);
const cmd: Command | undefined = program.commands.find((c: Command) => {
return c.name() === resource.name;
});
expect(cmd).toBeDefined();
}
});
it("should register list, get, create, update, delete, count subcommands for database resources", () => {
const program = new Command();
const program: Command = new Command();
program.exitOverride();
registerResourceCommands(program);
const incidentCmd = program.commands.find(
(c) => c.name() === "incident",
const incidentCmd: Command | undefined = program.commands.find(
(c: Command) => {
return c.name() === "incident";
},
);
expect(incidentCmd).toBeDefined();
const subcommands = incidentCmd!.commands.map((c) => c.name());
const subcommands: string[] = incidentCmd!.commands.map((c: Command) => {
return c.name();
});
expect(subcommands).toContain("list");
expect(subcommands).toContain("get");
expect(subcommands).toContain("create");
@@ -140,7 +162,7 @@ describe("ResourceCommands", () => {
describe("resource command actions", () => {
function createProgramWithResources(): Command {
const program = new Command();
const program: Command = new Command();
program.exitOverride();
program.configureOutput({
writeOut: () => {},
@@ -165,16 +187,18 @@ describe("ResourceCommands", () => {
describe("list subcommand", () => {
it("should call API with list operation", async () => {
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync(["node", "test", "incident", "list"]);
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
expect(mockExecuteApiRequest.mock.calls[0][0].operation).toBe("list");
expect(mockExecuteApiRequest.mock.calls[0][0].apiPath).toBe("/incident");
expect(mockExecuteApiRequest.mock.calls[0][0].apiPath).toBe(
"/incident",
);
});
it("should pass query, limit, skip, sort options", async () => {
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync([
"node",
"test",
@@ -191,7 +215,8 @@ describe("ResourceCommands", () => {
]);
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
const opts = mockExecuteApiRequest.mock.calls[0][0];
const opts: Record<string, unknown> =
mockExecuteApiRequest.mock.calls[0][0];
expect(opts.query).toEqual({ status: "active" });
expect(opts.limit).toBe(20);
expect(opts.skip).toBe(5);
@@ -203,7 +228,7 @@ describe("ResourceCommands", () => {
data: [{ _id: "1", name: "Test" }],
});
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync([
"node",
"test",
@@ -213,13 +238,14 @@ describe("ResourceCommands", () => {
"json",
]);
// eslint-disable-next-line no-console
expect(console.log).toHaveBeenCalled();
});
it("should handle response that is already an array", async () => {
mockExecuteApiRequest.mockResolvedValue([{ _id: "1" }]);
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync([
"node",
"test",
@@ -229,6 +255,7 @@ describe("ResourceCommands", () => {
"json",
]);
// eslint-disable-next-line no-console
expect(console.log).toHaveBeenCalled();
});
@@ -237,7 +264,7 @@ describe("ResourceCommands", () => {
new Error("API error (500): Server Error"),
);
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync(["node", "test", "incident", "list"]);
expect(process.exit).toHaveBeenCalled();
@@ -251,7 +278,7 @@ describe("ResourceCommands", () => {
name: "Test",
});
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync([
"node",
"test",
@@ -261,7 +288,8 @@ describe("ResourceCommands", () => {
]);
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
const opts = mockExecuteApiRequest.mock.calls[0][0];
const opts: Record<string, unknown> =
mockExecuteApiRequest.mock.calls[0][0];
expect(opts.operation).toBe("read");
expect(opts.id).toBe("abc-123");
});
@@ -269,7 +297,7 @@ describe("ResourceCommands", () => {
it("should support output format flag", async () => {
mockExecuteApiRequest.mockResolvedValue({ _id: "abc-123" });
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync([
"node",
"test",
@@ -280,13 +308,14 @@ describe("ResourceCommands", () => {
"json",
]);
// eslint-disable-next-line no-console
expect(console.log).toHaveBeenCalled();
});
it("should handle get errors", async () => {
mockExecuteApiRequest.mockRejectedValue(new Error("not found 404"));
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync([
"node",
"test",
@@ -303,7 +332,7 @@ describe("ResourceCommands", () => {
it("should call API with create operation and data", async () => {
mockExecuteApiRequest.mockResolvedValue({ _id: "new-123" });
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync([
"node",
"test",
@@ -314,7 +343,8 @@ describe("ResourceCommands", () => {
]);
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
const opts = mockExecuteApiRequest.mock.calls[0][0];
const opts: Record<string, unknown> =
mockExecuteApiRequest.mock.calls[0][0];
expect(opts.operation).toBe("create");
expect(opts.data).toEqual({ name: "New Incident" });
});
@@ -322,14 +352,14 @@ describe("ResourceCommands", () => {
it("should support reading data from a file", async () => {
mockExecuteApiRequest.mockResolvedValue({ _id: "new-123" });
const tmpFile = path.join(
const tmpFile: string = path.join(
os.tmpdir(),
"cli-test-" + Date.now() + ".json",
);
fs.writeFileSync(tmpFile, '{"name":"From File"}');
try {
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync([
"node",
"test",
@@ -349,14 +379,14 @@ describe("ResourceCommands", () => {
});
it("should error when neither --data nor --file is provided", async () => {
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync(["node", "test", "incident", "create"]);
expect(process.exit).toHaveBeenCalled();
});
it("should error on invalid JSON in --data", async () => {
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync([
"node",
"test",
@@ -374,7 +404,7 @@ describe("ResourceCommands", () => {
it("should call API with update operation, id, and data", async () => {
mockExecuteApiRequest.mockResolvedValue({ _id: "abc-123" });
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync([
"node",
"test",
@@ -386,7 +416,8 @@ describe("ResourceCommands", () => {
]);
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
const opts = mockExecuteApiRequest.mock.calls[0][0];
const opts: Record<string, unknown> =
mockExecuteApiRequest.mock.calls[0][0];
expect(opts.operation).toBe("update");
expect(opts.id).toBe("abc-123");
expect(opts.data).toEqual({ name: "Updated" });
@@ -395,7 +426,7 @@ describe("ResourceCommands", () => {
it("should handle update errors", async () => {
mockExecuteApiRequest.mockRejectedValue(new Error("API error"));
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync([
"node",
"test",
@@ -414,7 +445,7 @@ describe("ResourceCommands", () => {
it("should call API with delete operation and id", async () => {
mockExecuteApiRequest.mockResolvedValue({});
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync([
"node",
"test",
@@ -424,7 +455,8 @@ describe("ResourceCommands", () => {
]);
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
const opts = mockExecuteApiRequest.mock.calls[0][0];
const opts: Record<string, unknown> =
mockExecuteApiRequest.mock.calls[0][0];
expect(opts.operation).toBe("delete");
expect(opts.id).toBe("abc-123");
});
@@ -432,7 +464,7 @@ describe("ResourceCommands", () => {
it("should handle API errors", async () => {
mockExecuteApiRequest.mockRejectedValue(new Error("not found 404"));
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync([
"node",
"test",
@@ -449,18 +481,19 @@ describe("ResourceCommands", () => {
it("should call API with count operation", async () => {
mockExecuteApiRequest.mockResolvedValue({ count: 42 });
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync(["node", "test", "incident", "count"]);
expect(mockExecuteApiRequest).toHaveBeenCalledTimes(1);
expect(mockExecuteApiRequest.mock.calls[0][0].operation).toBe("count");
// eslint-disable-next-line no-console
expect(console.log).toHaveBeenCalledWith(42);
});
it("should pass query filter", async () => {
mockExecuteApiRequest.mockResolvedValue({ count: 5 });
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync([
"node",
"test",
@@ -478,25 +511,27 @@ describe("ResourceCommands", () => {
it("should handle response without count field", async () => {
mockExecuteApiRequest.mockResolvedValue(99);
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync(["node", "test", "incident", "count"]);
// eslint-disable-next-line no-console
expect(console.log).toHaveBeenCalledWith(99);
});
it("should handle non-object response in count", async () => {
mockExecuteApiRequest.mockResolvedValue("some-string");
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync(["node", "test", "incident", "count"]);
// eslint-disable-next-line no-console
expect(console.log).toHaveBeenCalledWith("some-string");
});
it("should handle count errors", async () => {
mockExecuteApiRequest.mockRejectedValue(new Error("API error"));
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync(["node", "test", "incident", "count"]);
expect(process.exit).toHaveBeenCalled();
@@ -508,7 +543,7 @@ describe("ResourceCommands", () => {
ConfigManager.removeContext("test");
mockExecuteApiRequest.mockResolvedValue({ data: [] });
const program = createProgramWithResources();
const program: Command = createProgramWithResources();
await program.parseAsync([
"node",
"test",

View File

@@ -1,22 +1,29 @@
import { generateAllFieldsSelect } from "../Utils/SelectFieldGenerator";
import { JSONObject } from "Common/Types/JSON";
describe("SelectFieldGenerator", () => {
describe("generateAllFieldsSelect", () => {
describe("database models", () => {
it("should return fields for a known database model (Incident)", () => {
const select = generateAllFieldsSelect("Incident", "database");
const select: JSONObject = generateAllFieldsSelect(
"Incident",
"database",
);
expect(Object.keys(select).length).toBeGreaterThan(0);
// Should have some common fields
expect(select).toHaveProperty("_id");
});
it("should return fields for Monitor model", () => {
const select = generateAllFieldsSelect("Monitor", "database");
const select: JSONObject = generateAllFieldsSelect(
"Monitor",
"database",
);
expect(Object.keys(select).length).toBeGreaterThan(0);
});
it("should return default select for unknown database model", () => {
const select = generateAllFieldsSelect(
const select: JSONObject = generateAllFieldsSelect(
"NonExistentModel12345",
"database",
);
@@ -29,7 +36,10 @@ describe("SelectFieldGenerator", () => {
it("should filter fields based on access control", () => {
// Testing with a real model that has access control
const select = generateAllFieldsSelect("Incident", "database");
const select: JSONObject = generateAllFieldsSelect(
"Incident",
"database",
);
// We just verify it returns something reasonable
expect(typeof select).toBe("object");
expect(Object.keys(select).length).toBeGreaterThan(0);
@@ -39,7 +49,10 @@ describe("SelectFieldGenerator", () => {
describe("analytics models", () => {
it("should return default select for known analytics model (LogItem)", () => {
// The Log analytics model has tableName "LogItem"
const select = generateAllFieldsSelect("LogItem", "analytics");
const select: JSONObject = generateAllFieldsSelect(
"LogItem",
"analytics",
);
expect(select).toEqual({
_id: true,
createdAt: true,
@@ -48,7 +61,7 @@ describe("SelectFieldGenerator", () => {
});
it("should return default select for unknown analytics model", () => {
const select = generateAllFieldsSelect(
const select: JSONObject = generateAllFieldsSelect(
"NonExistentAnalytics",
"analytics",
);
@@ -62,7 +75,7 @@ describe("SelectFieldGenerator", () => {
describe("edge cases", () => {
it("should return default select for unknown model type", () => {
const select = generateAllFieldsSelect(
const select: JSONObject = generateAllFieldsSelect(
"Incident",
"unknown" as any,
);
@@ -74,7 +87,7 @@ describe("SelectFieldGenerator", () => {
});
it("should return default select for empty tableName", () => {
const select = generateAllFieldsSelect("", "database");
const select: JSONObject = generateAllFieldsSelect("", "database");
expect(select).toEqual({
_id: true,
createdAt: true,
@@ -83,14 +96,20 @@ describe("SelectFieldGenerator", () => {
});
it("should handle outer exception and return default select", () => {
const DatabaseModels = require("Common/Models/DatabaseModels/Index").default;
const origFind = DatabaseModels.find;
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
const DatabaseModels: Record<string, unknown> =
require("Common/Models/DatabaseModels/Index").default;
/* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
const origFind: unknown = DatabaseModels.find;
try {
DatabaseModels.find = () => {
DatabaseModels.find = (): never => {
throw new Error("Simulated error");
};
const select = generateAllFieldsSelect("Incident", "database");
const select: JSONObject = generateAllFieldsSelect(
"Incident",
"database",
);
expect(select).toEqual({
_id: true,
createdAt: true,
@@ -102,12 +121,22 @@ describe("SelectFieldGenerator", () => {
});
it("should return default when getTableColumns returns empty", () => {
const tableColumnModule = require("Common/Types/Database/TableColumn");
const origGetTableColumns = tableColumnModule.getTableColumns;
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
const tableColumnModule: Record<
string,
unknown
> = require("Common/Types/Database/TableColumn");
/* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
const origGetTableColumns: unknown = tableColumnModule.getTableColumns;
try {
tableColumnModule.getTableColumns = () => ({});
tableColumnModule.getTableColumns = (): Record<string, unknown> => {
return {};
};
const select = generateAllFieldsSelect("Incident", "database");
const select: JSONObject = generateAllFieldsSelect(
"Incident",
"database",
);
expect(select).toEqual({
_id: true,
createdAt: true,
@@ -119,29 +148,51 @@ describe("SelectFieldGenerator", () => {
});
it("should return default when all columns are filtered out", () => {
const tableColumnModule = require("Common/Types/Database/TableColumn");
const origGetTableColumns = tableColumnModule.getTableColumns;
const DatabaseModels = require("Common/Models/DatabaseModels/Index").default;
const origFind = DatabaseModels.find;
const Permission = require("Common/Types/Permission").default;
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
const tableColumnModule: Record<
string,
unknown
> = require("Common/Types/Database/TableColumn");
const origGetTableColumns: unknown = tableColumnModule.getTableColumns;
const DatabaseModels: Record<string, unknown> =
require("Common/Models/DatabaseModels/Index").default;
const origFind: unknown = DatabaseModels.find;
const Permission: Record<string, unknown> =
require("Common/Types/Permission").default;
/* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
try {
tableColumnModule.getTableColumns = () => ({ field1: {}, field2: {} });
tableColumnModule.getTableColumns = (): Record<
string,
Record<string, unknown>
> => {
return { field1: {}, field2: {} };
};
DatabaseModels.find = (fn) => {
function MockModel() {
DatabaseModels.find = (fn: (model: unknown) => boolean): unknown => {
function MockModel(this: Record<string, unknown>): void {
this.tableName = "MockTable";
this.getColumnAccessControlForAllColumns = () => ({
field1: { read: [Permission.CurrentUser] },
field2: { read: [Permission.CurrentUser] },
});
this.getColumnAccessControlForAllColumns = (): Record<
string,
unknown
> => {
return {
field1: { read: [Permission.CurrentUser] },
field2: { read: [Permission.CurrentUser] },
};
};
}
const matches: boolean = fn(MockModel);
if (matches) {
return MockModel;
}
const matches = fn(MockModel);
if (matches) return MockModel;
return undefined;
};
const select = generateAllFieldsSelect("MockTable", "database");
const select: JSONObject = generateAllFieldsSelect(
"MockTable",
"database",
);
expect(select).toEqual({
_id: true,
createdAt: true,

View File

@@ -1,13 +1,12 @@
import { Command } from "commander";
import { registerUtilityCommands } from "../Commands/UtilityCommands";
import { registerResourceCommands } from "../Commands/ResourceCommands";
import * as ConfigManager from "../Core/ConfigManager";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
const CONFIG_DIR = path.join(os.homedir(), ".oneuptime");
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
const CONFIG_DIR: string = path.join(os.homedir(), ".oneuptime");
const CONFIG_FILE: string = path.join(CONFIG_DIR, "config.json");
describe("UtilityCommands", () => {
let originalConfigContent: string | null = null;
@@ -46,7 +45,7 @@ describe("UtilityCommands", () => {
});
function createProgram(): Command {
const program = new Command();
const program: Command = new Command();
program.exitOverride();
program.configureOutput({
writeOut: () => {},
@@ -62,18 +61,18 @@ describe("UtilityCommands", () => {
describe("version command", () => {
it("should print version", async () => {
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync(["node", "test", "version"]);
expect(consoleLogSpy).toHaveBeenCalled();
// Should print a version string (either from package.json or fallback)
const versionArg = consoleLogSpy.mock.calls[0][0];
const versionArg: string = consoleLogSpy.mock.calls[0][0];
expect(typeof versionArg).toBe("string");
});
});
describe("whoami command", () => {
it("should show not authenticated when no credentials", async () => {
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync(["node", "test", "whoami"]);
expect(consoleLogSpy).toHaveBeenCalled();
});
@@ -85,7 +84,7 @@ describe("UtilityCommands", () => {
apiKey: "abcdefghijklm",
});
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync(["node", "test", "whoami"]);
expect(consoleLogSpy).toHaveBeenCalledWith("URL: https://test.com");
@@ -102,7 +101,7 @@ describe("UtilityCommands", () => {
apiKey: "abc",
});
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync(["node", "test", "whoami"]);
expect(consoleLogSpy).toHaveBeenCalledWith("API Key: ****");
@@ -112,7 +111,7 @@ describe("UtilityCommands", () => {
process.env["ONEUPTIME_API_KEY"] = "env-key-long-enough";
process.env["ONEUPTIME_URL"] = "https://env.com";
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync(["node", "test", "whoami"]);
expect(consoleLogSpy).toHaveBeenCalledWith("URL: https://env.com");
@@ -120,13 +119,13 @@ describe("UtilityCommands", () => {
it("should handle whoami outer catch block", async () => {
// Mock getCurrentContext to throw an unexpected error
const spy = jest
const spy: jest.SpyInstance = jest
.spyOn(ConfigManager, "getCurrentContext")
.mockImplementation(() => {
throw new Error("Unexpected crash");
});
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync(["node", "test", "whoami"]);
expect(exitSpy).toHaveBeenCalledWith(1);
@@ -137,12 +136,14 @@ describe("UtilityCommands", () => {
process.env["ONEUPTIME_API_KEY"] = "env-key-long-enough";
process.env["ONEUPTIME_URL"] = "https://env.com";
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync(["node", "test", "whoami"]);
// Should NOT have a "Context:" call since no context is set
const contextCalls = consoleLogSpy.mock.calls.filter(
(call: any[]) => typeof call[0] === "string" && call[0].startsWith("Context:"),
const contextCalls: any[][] = consoleLogSpy.mock.calls.filter(
(call: any[]) => {
return typeof call[0] === "string" && call[0].startsWith("Context:");
},
);
expect(contextCalls).toHaveLength(0);
});
@@ -150,20 +151,22 @@ describe("UtilityCommands", () => {
describe("resources command", () => {
it("should list all resources", async () => {
// We need registerResourceCommands for discoverResources to work
// but discoverResources is imported directly, so it should work
const program = createProgram();
/*
* We need registerResourceCommands for discoverResources to work
* but discoverResources is imported directly, so it should work
*/
const program: Command = createProgram();
await program.parseAsync(["node", "test", "resources"]);
expect(consoleLogSpy).toHaveBeenCalled();
// Should show total count
const lastCall =
const lastCall: string =
consoleLogSpy.mock.calls[consoleLogSpy.mock.calls.length - 1][0];
expect(lastCall).toContain("Total:");
});
it("should filter by type", async () => {
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync([
"node",
"test",
@@ -176,7 +179,7 @@ describe("UtilityCommands", () => {
});
it("should show message when filter returns no results", async () => {
const program = createProgram();
const program: Command = createProgram();
await program.parseAsync([
"node",
"test",

View File

@@ -34,9 +34,7 @@ export function generateAllFieldsSelect(
): JSONObject {
try {
if (modelType === "database") {
const ModelClass:
| (new () => BaseModel)
| undefined = DatabaseModels.find(
const ModelClass: (new () => BaseModel) | undefined = DatabaseModels.find(
(Model: new () => BaseModel): boolean => {
try {
const instance: BaseModel = new Model();
@@ -85,18 +83,15 @@ export function generateAllFieldsSelect(
}
if (modelType === "analytics") {
const ModelClass:
| (new () => AnalyticsBaseModel)
| undefined = AnalyticsModels.find(
(Model: new () => AnalyticsBaseModel): boolean => {
const ModelClass: (new () => AnalyticsBaseModel) | undefined =
AnalyticsModels.find((Model: new () => AnalyticsBaseModel): boolean => {
try {
const instance: AnalyticsBaseModel = new Model();
return instance.tableName === tableName;
} catch {
return false;
}
},
);
});
if (!ModelClass) {
return getDefaultSelect();

View File

@@ -3,7 +3,9 @@ import ProjectUtil from "Common/UI/Utils/Project";
import PageComponentProps from "../PageComponentProps";
import React, { FunctionComponent, ReactElement } from "react";
const DisabledMonitors: FunctionComponent<PageComponentProps> = (): ReactElement => {
const DisabledMonitors: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
return (
<MonitorTable
query={{

View File

@@ -3,7 +3,9 @@ import ProjectUtil from "Common/UI/Utils/Project";
import PageComponentProps from "../PageComponentProps";
import React, { FunctionComponent, ReactElement } from "react";
const NotOperationalMonitors: FunctionComponent<PageComponentProps> = (): ReactElement => {
const NotOperationalMonitors: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
return (
<MonitorTable
query={{

View File

@@ -3,7 +3,9 @@ import ProjectUtil from "Common/UI/Utils/Project";
import PageComponentProps from "../PageComponentProps";
import React, { FunctionComponent, ReactElement } from "react";
const DisabledMonitors: FunctionComponent<PageComponentProps> = (): ReactElement => {
const DisabledMonitors: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
return (
<MonitorTable
query={{

View File

@@ -3,7 +3,9 @@ import ProjectUtil from "Common/UI/Utils/Project";
import PageComponentProps from "../PageComponentProps";
import React, { FunctionComponent, ReactElement } from "react";
const DisabledMonitors: FunctionComponent<PageComponentProps> = (): ReactElement => {
const DisabledMonitors: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
return (
<MonitorTable
query={{

View File

@@ -2,7 +2,9 @@ import IncomingCallNumber from "../../Components/NotificationMethods/IncomingCal
import PageComponentProps from "../PageComponentProps";
import React, { FunctionComponent, ReactElement } from "react";
const IncomingCallPhoneNumbers: FunctionComponent<PageComponentProps> = (): ReactElement => {
const IncomingCallPhoneNumbers: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
return <IncomingCallNumber />;
};

View File

@@ -39,7 +39,9 @@ type OnCallDutyPolicy = InstanceType<typeof OnCallDutyPolicyModel>;
type OnCallDutyPolicyEscalationRule = InstanceType<
typeof OnCallDutyPolicyEscalationRuleModel
>;
type OnCallDutyPolicySchedule = InstanceType<typeof OnCallDutyPolicyScheduleModel>;
type OnCallDutyPolicySchedule = InstanceType<
typeof OnCallDutyPolicyScheduleModel
>;
type Project = InstanceType<typeof ProjectModel>;
type Team = InstanceType<typeof TeamModel>;
type User = InstanceType<typeof UserModel>;
@@ -90,7 +92,12 @@ type IncidentItemFromCommon = RequiredModelFields<
export interface IncidentItem
extends Omit<
IncidentItemFromCommon,
"declaredAt" | "createdAt" | "currentIncidentState" | "incidentSeverity" | "monitors" | "projectId"
| "declaredAt"
| "createdAt"
| "currentIncidentState"
| "incidentSeverity"
| "monitors"
| "projectId"
> {
rootCause?: string;
declaredAt: string;
@@ -115,7 +122,11 @@ type AlertItemFromCommon = RequiredModelFields<
export interface AlertItem
extends Omit<
AlertItemFromCommon,
"createdAt" | "currentAlertState" | "alertSeverity" | "monitor" | "projectId"
| "createdAt"
| "currentAlertState"
| "alertSeverity"
| "monitor"
| "projectId"
> {
rootCause?: string;
createdAt: string;
@@ -129,7 +140,12 @@ export interface AlertItem
export interface IncidentState
extends RequiredModelFields<
IncidentStateModel,
"_id" | "name" | "isResolvedState" | "isAcknowledgedState" | "isCreatedState" | "order"
| "_id"
| "name"
| "isResolvedState"
| "isAcknowledgedState"
| "isCreatedState"
| "order"
> {
color: ColorField;
}
@@ -137,7 +153,12 @@ export interface IncidentState
export interface AlertState
extends RequiredModelFields<
AlertStateModel,
"_id" | "name" | "isResolvedState" | "isAcknowledgedState" | "isCreatedState" | "order"
| "_id"
| "name"
| "isResolvedState"
| "isAcknowledgedState"
| "isCreatedState"
| "order"
> {
color: ColorField;
}
@@ -170,7 +191,11 @@ type IncidentEpisodeItemFromCommon = RequiredModelFields<
export interface IncidentEpisodeItem
extends Omit<
IncidentEpisodeItemFromCommon,
"createdAt" | "declaredAt" | "currentIncidentState" | "incidentSeverity" | "projectId"
| "createdAt"
| "declaredAt"
| "currentIncidentState"
| "incidentSeverity"
| "projectId"
> {
rootCause?: string;
createdAt: string;
@@ -212,7 +237,8 @@ type NoteItemFromCommon = RequiredModelFields<
"_id" | "note" | "createdAt"
>;
export interface NoteItem extends Omit<NoteItemFromCommon, "createdAt" | "createdByUser"> {
export interface NoteItem
extends Omit<NoteItemFromCommon, "createdAt" | "createdByUser"> {
createdAt: string;
createdByUser:
| (RequiredModelFields<User, "_id" | "name"> & {
@@ -222,9 +248,13 @@ export interface NoteItem extends Omit<NoteItemFromCommon, "createdAt" | "create
| null;
}
type FeedItemFromCommon = RequiredModelFields<AlertFeed | IncidentFeed, "_id" | "feedInfoInMarkdown" | "createdAt">;
type FeedItemFromCommon = RequiredModelFields<
AlertFeed | IncidentFeed,
"_id" | "feedInfoInMarkdown" | "createdAt"
>;
export interface FeedItem extends Omit<FeedItemFromCommon, "createdAt" | "displayColor" | "postedAt"> {
export interface FeedItem
extends Omit<FeedItemFromCommon, "createdAt" | "displayColor" | "postedAt"> {
feedInfoInMarkdown: string;
moreInformationInMarkdown?: string;
displayColor: ColorField;

View File

@@ -79,7 +79,10 @@ export default function AlertCard({
/>
<Text
className="text-[10px] font-semibold"
style={{ color: theme.colors.textSecondary, letterSpacing: 0.3 }}
style={{
color: theme.colors.textSecondary,
letterSpacing: 0.3,
}}
>
ALERT
</Text>

View File

@@ -111,7 +111,10 @@ export default function EpisodeCard(
/>
<Text
className="text-[10px] font-semibold"
style={{ color: theme.colors.textSecondary, letterSpacing: 0.3 }}
style={{
color: theme.colors.textSecondary,
letterSpacing: 0.3,
}}
>
{type === "incident" ? "INCIDENT EPISODE" : "ALERT EPISODE"}
</Text>
@@ -131,7 +134,8 @@ export default function EpisodeCard(
letterSpacing: 0.2,
}}
>
{episode.episodeNumberWithPrefix || `#${episode.episodeNumber}`}
{episode.episodeNumberWithPrefix ||
`#${episode.episodeNumber}`}
</Text>
</View>
</View>

View File

@@ -87,7 +87,10 @@ export default function IncidentCard({
/>
<Text
className="text-[10px] font-semibold"
style={{ color: theme.colors.textSecondary, letterSpacing: 0.3 }}
style={{
color: theme.colors.textSecondary,
letterSpacing: 0.3,
}}
>
INCIDENT
</Text>

File diff suppressed because one or more lines are too long

View File

@@ -96,4 +96,4 @@ export default function MarkdownContent({
{markdownText}
</Markdown>
);
}
}

View File

@@ -75,7 +75,9 @@ export default function NotesSection({
backgroundColor: theme.colors.backgroundElevated,
borderWidth: 1,
borderColor: theme.colors.borderGlass,
shadowColor: theme.isDark ? "#000" : theme.colors.accentGradientMid,
shadowColor: theme.isDark
? "#000"
: theme.colors.accentGradientMid,
shadowOpacity: theme.isDark ? 0.16 : 0.06,
shadowOffset: { width: 0, height: 5 },
shadowRadius: 10,

View File

@@ -35,4 +35,4 @@ export default function RootCauseCard({
</View>
</View>
);
}
}

View File

@@ -23,7 +23,11 @@ export default function SectionHeader({
borderColor: theme.colors.borderGlass,
}}
>
<Ionicons name={iconName} size={13} color={theme.colors.actionPrimary} />
<Ionicons
name={iconName}
size={13}
color={theme.colors.actionPrimary}
/>
</View>
<Text
className="text-[12px] font-semibold uppercase"

View File

@@ -58,7 +58,9 @@ export default function SegmentedControl<T extends string>({
<Text
className="text-body-sm font-semibold"
style={{
color: isActive ? activeContentColor : theme.colors.textSecondary,
color: isActive
? activeContentColor
: theme.colors.textSecondary,
letterSpacing: 0.2,
}}
>

View File

@@ -17,7 +17,10 @@ interface UseAllProjectOnCallPoliciesResult {
refetch: () => Promise<void>;
}
function getEntityId(entity?: { _id?: string; id?: string }): string | undefined {
function getEntityId(entity?: {
_id?: string;
id?: string;
}): string | undefined {
return entity?._id ?? entity?.id;
}
@@ -33,7 +36,8 @@ function toAssignments(
projectName: project.name,
policyId: getEntityId(rule.onCallDutyPolicy),
policyName: rule.onCallDutyPolicy?.name ?? "Unknown policy",
escalationRuleName: rule.onCallDutyPolicyEscalationRule?.name ?? "Unknown rule",
escalationRuleName:
rule.onCallDutyPolicyEscalationRule?.name ?? "Unknown rule",
assignmentType: "user",
assignmentDetail: "You are directly assigned",
});
@@ -45,7 +49,8 @@ function toAssignments(
projectName: project.name,
policyId: getEntityId(rule.onCallDutyPolicy),
policyName: rule.onCallDutyPolicy?.name ?? "Unknown policy",
escalationRuleName: rule.onCallDutyPolicyEscalationRule?.name ?? "Unknown rule",
escalationRuleName:
rule.onCallDutyPolicyEscalationRule?.name ?? "Unknown rule",
assignmentType: "team",
assignmentDetail: `Via team: ${rule.team?.name ?? "Unknown"}`,
});
@@ -57,7 +62,8 @@ function toAssignments(
projectName: project.name,
policyId: getEntityId(rule.onCallDutyPolicy),
policyName: rule.onCallDutyPolicy?.name ?? "Unknown policy",
escalationRuleName: rule.onCallDutyPolicyEscalationRule?.name ?? "Unknown rule",
escalationRuleName:
rule.onCallDutyPolicyEscalationRule?.name ?? "Unknown rule",
assignmentType: "schedule",
assignmentDetail: `Via schedule: ${rule.onCallDutyPolicySchedule?.name ?? "Unknown"}`,
});
@@ -107,15 +113,19 @@ export function useAllProjectOnCallPolicies(): UseAllProjectOnCallPoliciesResult
const projects: ProjectOnCallAssignments[] = [];
results.forEach((result: PromiseSettledResult<ProjectOnCallAssignments | null>) => {
if (result.status === "fulfilled" && result.value) {
projects.push(result.value);
}
});
results.forEach(
(result: PromiseSettledResult<ProjectOnCallAssignments | null>) => {
if (result.status === "fulfilled" && result.value) {
projects.push(result.value);
}
},
);
return projects.sort((a: ProjectOnCallAssignments, b: ProjectOnCallAssignments) => {
return a.projectName.localeCompare(b.projectName);
});
return projects.sort(
(a: ProjectOnCallAssignments, b: ProjectOnCallAssignments) => {
return a.projectName.localeCompare(b.projectName);
},
);
},
});

View File

@@ -74,7 +74,9 @@ export default function MainTabNavigator(): React.JSX.Element {
height: Platform.OS === "ios" ? 78 : 68,
paddingBottom: Platform.OS === "ios" ? 18 : 10,
paddingTop: 10,
shadowColor: theme.isDark ? "#000000" : theme.colors.accentGradientMid,
shadowColor: theme.isDark
? "#000000"
: theme.colors.accentGradientMid,
shadowOpacity: theme.isDark ? 0.35 : 0.12,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 18,

View File

@@ -5,8 +5,9 @@ import { useTheme } from "../theme";
import MyOnCallPoliciesScreen from "../screens/MyOnCallPoliciesScreen";
import type { OnCallStackParamList } from "./types";
const Stack: ReturnType<typeof createNativeStackNavigator<OnCallStackParamList>> =
createNativeStackNavigator<OnCallStackParamList>();
const Stack: ReturnType<
typeof createNativeStackNavigator<OnCallStackParamList>
> = createNativeStackNavigator<OnCallStackParamList>();
export default function OnCallStackNavigator(): React.JSX.Element {
const { theme } = useTheme();

View File

@@ -185,7 +185,8 @@ export default function AlertDetailScreen({ route }: Props): React.JSX.Element {
const isResolved: boolean = resolveState?._id === currentStateId;
const isAcknowledged: boolean = acknowledgeState?._id === currentStateId;
const rootCauseTextRaw: string = toPlainText(alert.rootCause);
const rootCauseText: string | undefined = rootCauseTextRaw.trim() || undefined;
const rootCauseText: string | undefined =
rootCauseTextRaw.trim() || undefined;
const descriptionText: string = toPlainText(alert.description);
return (
@@ -373,78 +374,81 @@ export default function AlertDetailScreen({ route }: Props): React.JSX.Element {
}}
>
<View className="flex-row gap-3">
{!isAcknowledged && !isResolved && acknowledgeState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateAcknowledged,
}}
onPress={() => {
return handleStateChange(
acknowledgeState._id,
acknowledgeState.name,
);
}}
disabled={changingState}
activeOpacity={0.85}
accessibilityRole="button"
accessibilityLabel="Acknowledge alert"
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-circle-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Acknowledge
</Text>
</>
)}
</TouchableOpacity>
) : null}
{!isAcknowledged && !isResolved && acknowledgeState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateAcknowledged,
}}
onPress={() => {
return handleStateChange(
acknowledgeState._id,
acknowledgeState.name,
);
}}
disabled={changingState}
activeOpacity={0.85}
accessibilityRole="button"
accessibilityLabel="Acknowledge alert"
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-circle-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Acknowledge
</Text>
</>
)}
</TouchableOpacity>
) : null}
{resolveState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateResolved,
}}
onPress={() => {
return handleStateChange(resolveState._id, resolveState.name);
}}
disabled={changingState}
activeOpacity={0.85}
accessibilityRole="button"
accessibilityLabel="Resolve alert"
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-done-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Resolve
</Text>
</>
)}
</TouchableOpacity>
) : null}
{resolveState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateResolved,
}}
onPress={() => {
return handleStateChange(
resolveState._id,
resolveState.name,
);
}}
disabled={changingState}
activeOpacity={0.85}
accessibilityRole="button"
accessibilityLabel="Resolve alert"
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-done-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Resolve
</Text>
</>
)}
</TouchableOpacity>
) : null}
</View>
</View>
</View>

View File

@@ -193,7 +193,8 @@ export default function AlertEpisodeDetailScreen({
const isResolved: boolean = resolveState?._id === currentStateId;
const isAcknowledged: boolean = acknowledgeState?._id === currentStateId;
const rootCauseTextRaw: string = toPlainText(episode.rootCause);
const rootCauseText: string | undefined = rootCauseTextRaw.trim() || undefined;
const rootCauseText: string | undefined =
rootCauseTextRaw.trim() || undefined;
const descriptionText: string = toPlainText(episode.description);
return (
@@ -381,71 +382,74 @@ export default function AlertEpisodeDetailScreen({
}}
>
<View className="flex-row gap-3">
{!isAcknowledged && !isResolved && acknowledgeState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateAcknowledged,
}}
onPress={() => {
return handleStateChange(
acknowledgeState._id,
acknowledgeState.name,
);
}}
disabled={changingState}
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-circle-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Acknowledge
</Text>
</>
)}
</TouchableOpacity>
) : null}
{resolveState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateResolved,
}}
onPress={() => {
return handleStateChange(resolveState._id, resolveState.name);
}}
disabled={changingState}
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-done-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Resolve
</Text>
</>
)}
</TouchableOpacity>
) : null}
{!isAcknowledged && !isResolved && acknowledgeState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateAcknowledged,
}}
onPress={() => {
return handleStateChange(
acknowledgeState._id,
acknowledgeState.name,
);
}}
disabled={changingState}
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-circle-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Acknowledge
</Text>
</>
)}
</TouchableOpacity>
) : null}
{resolveState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateResolved,
}}
onPress={() => {
return handleStateChange(
resolveState._id,
resolveState.name,
);
}}
disabled={changingState}
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-done-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Resolve
</Text>
</>
)}
</TouchableOpacity>
) : null}
</View>
</View>
</View>

View File

@@ -56,7 +56,10 @@ function StatCard({
accessibilityRole="button"
>
<LinearGradient
colors={[theme.colors.accentGradientStart + "2B", theme.colors.accentGradientEnd + "1A"]}
colors={[
theme.colors.accentGradientStart + "2B",
theme.colors.accentGradientEnd + "1A",
]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={{
@@ -353,7 +356,9 @@ export default function HomeScreen(): React.JSX.Element {
backgroundColor: theme.colors.backgroundElevated,
borderWidth: 1,
borderColor: theme.colors.borderGlass,
shadowColor: theme.isDark ? "#000" : theme.colors.accentGradientMid,
shadowColor: theme.isDark
? "#000"
: theme.colors.accentGradientMid,
shadowOpacity: theme.isDark ? 0.24 : 0.09,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 14,

View File

@@ -199,7 +199,8 @@ export default function IncidentDetailScreen({
const isResolved: boolean = resolveState?._id === currentStateId;
const isAcknowledged: boolean = acknowledgeState?._id === currentStateId;
const rootCauseTextRaw: string = toPlainText(incident.rootCause);
const rootCauseText: string | undefined = rootCauseTextRaw.trim() || undefined;
const rootCauseText: string | undefined =
rootCauseTextRaw.trim() || undefined;
const descriptionText: string = toPlainText(incident.description);
return (
@@ -408,78 +409,81 @@ export default function IncidentDetailScreen({
}}
>
<View className="flex-row gap-3">
{!isAcknowledged && !isResolved && acknowledgeState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateAcknowledged,
}}
onPress={() => {
return handleStateChange(
acknowledgeState._id,
acknowledgeState.name,
);
}}
disabled={changingState}
activeOpacity={0.85}
accessibilityRole="button"
accessibilityLabel="Acknowledge incident"
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-circle-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Acknowledge
</Text>
</>
)}
</TouchableOpacity>
) : null}
{!isAcknowledged && !isResolved && acknowledgeState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateAcknowledged,
}}
onPress={() => {
return handleStateChange(
acknowledgeState._id,
acknowledgeState.name,
);
}}
disabled={changingState}
activeOpacity={0.85}
accessibilityRole="button"
accessibilityLabel="Acknowledge incident"
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-circle-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Acknowledge
</Text>
</>
)}
</TouchableOpacity>
) : null}
{resolveState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateResolved,
}}
onPress={() => {
return handleStateChange(resolveState._id, resolveState.name);
}}
disabled={changingState}
activeOpacity={0.85}
accessibilityRole="button"
accessibilityLabel="Resolve incident"
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-done-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Resolve
</Text>
</>
)}
</TouchableOpacity>
) : null}
{resolveState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateResolved,
}}
onPress={() => {
return handleStateChange(
resolveState._id,
resolveState.name,
);
}}
disabled={changingState}
activeOpacity={0.85}
accessibilityRole="button"
accessibilityLabel="Resolve incident"
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-done-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Resolve
</Text>
</>
)}
</TouchableOpacity>
) : null}
</View>
</View>
</View>

View File

@@ -202,7 +202,8 @@ export default function IncidentEpisodeDetailScreen({
const isResolved: boolean = resolveState?._id === currentStateId;
const isAcknowledged: boolean = acknowledgeState?._id === currentStateId;
const rootCauseTextRaw: string = toPlainText(episode.rootCause);
const rootCauseText: string | undefined = rootCauseTextRaw.trim() || undefined;
const rootCauseText: string | undefined =
rootCauseTextRaw.trim() || undefined;
const descriptionText: string = toPlainText(episode.description);
return (
@@ -388,71 +389,74 @@ export default function IncidentEpisodeDetailScreen({
}}
>
<View className="flex-row gap-3">
{!isAcknowledged && !isResolved && acknowledgeState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateAcknowledged,
}}
onPress={() => {
return handleStateChange(
acknowledgeState._id,
acknowledgeState.name,
);
}}
disabled={changingState}
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-circle-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Acknowledge
</Text>
</>
)}
</TouchableOpacity>
) : null}
{resolveState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateResolved,
}}
onPress={() => {
return handleStateChange(resolveState._id, resolveState.name);
}}
disabled={changingState}
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-done-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Resolve
</Text>
</>
)}
</TouchableOpacity>
) : null}
{!isAcknowledged && !isResolved && acknowledgeState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateAcknowledged,
}}
onPress={() => {
return handleStateChange(
acknowledgeState._id,
acknowledgeState.name,
);
}}
disabled={changingState}
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-circle-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Acknowledge
</Text>
</>
)}
</TouchableOpacity>
) : null}
{resolveState ? (
<TouchableOpacity
className="flex-1 flex-row py-3 rounded-xl items-center justify-center min-h-[48px] overflow-hidden"
style={{
backgroundColor: theme.colors.stateResolved,
}}
onPress={() => {
return handleStateChange(
resolveState._id,
resolveState.name,
);
}}
disabled={changingState}
>
{changingState ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons
name="checkmark-done-outline"
size={17}
color="#FFFFFF"
style={{ marginRight: 6 }}
/>
<Text
className="text-[14px] font-bold"
style={{ color: "#FFFFFF" }}
>
Resolve
</Text>
</>
)}
</TouchableOpacity>
) : null}
</View>
</View>
</View>