Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/comprehensive-improvements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"frames.js": patch
---

### Improvements

- Added comprehensive test coverage for utility functions (`escapeHtmlAttributeValue`, `isValidVersion`, button type guards, `bytesToHexString`, `getByteLength`, `hexStringToUint8Array`, `normalizeCastId`)
- Added tests for core utilities (`joinPaths`, `parseSearchParams`, `isFrameRedirect`, `isFrameDefinition`)
- Added tests for error classes (`RequestBodyNotJSONError`, `InvalidFrameActionPayloadError`, `FrameMessageError`)
- Enhanced JSDoc documentation across utility functions and middleware
- Improved CONTRIBUTING.md with detailed setup, testing, and PR guidelines
106 changes: 96 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,113 @@
# Set up for local development
# Contributing to frames.js

First, ensure that the following are installed globally on your machine:
Thank you for your interest in contributing to frames.js! This guide will help you get started.

## Prerequisites

Ensure that the following are installed globally on your machine:

- Node.js 18.7+
- Yarn
- Yarn (1.22.x)

## Set up for local development

1. Clone the repository:
```bash
git clone https://github.com/framesjs/frames.js.git
cd frames.js
```

2. Install dependencies:
```bash
yarn install
```

3. Run all packages in development mode:
```bash
yarn dev
```

4. To run any individual package from its directory:
```bash
cd packages/<package-name>
yarn dev
```
Note: You may need to rebuild other packages first.

## Running Tests

Run the full test suite:
```bash
yarn test:ci
```

Run tests in watch mode (for development):
```bash
yarn test
```

## Code Style

1. In the root directory, run `yarn install`
2. Run all packages by running `yarn dev` from the root folder.
3. To run any individual package from it's directory, run `yarn dev`, but you may need to rebuild any other packages
- We use Prettier for code formatting. Run `yarn format` to format your code.
- Follow TypeScript best practices and ensure your code passes linting with `yarn lint`.
- Add JSDoc comments to public functions and classes.

# Changesets
## Changesets

All PRs with meaningful changes should have a changeset which is a short description of the modifications being made to each package. Changesets are automatically converted into a changelog when the repo manager runs a release process.

## Add a new changeset
### Add a new changeset

```bash
yarn changeset
```

## Create new versions of packages
### Create new versions of packages

```bash
yarn changeset version
```

## Publish all changed packages to npm
### Publish all changed packages to npm

```bash
yarn changeset publish
git push --follow-tags origin main
```

## Pull Request Checklist

Before submitting your PR, please ensure:

- [ ] Code follows the project's coding standards
- [ ] All tests pass (`yarn test:ci`)
- [ ] Linting passes (`yarn lint`)
- [ ] You've added a changeset if applicable
- [ ] You've added/updated tests for your changes
- [ ] You've updated documentation if needed
- [ ] Your commit messages follow conventional commits format

## Troubleshooting

### TypeScript Config Issues

If you encounter TypeScript configuration errors, try:
```bash
yarn build:ci
```

This will build all packages and resolve type dependencies.

### Dependency Issues

If packages aren't resolving correctly:
```bash
rm -rf node_modules
yarn install
```

## Need Help?

- Join the [/frames-dev](https://warpcast.com/frames-dev) channel on Farcaster
- Check existing issues on GitHub
- Read the [documentation](https://framesjs.org)
86 changes: 86 additions & 0 deletions packages/frames.js/src/core/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
RequestBodyNotJSONError,
InvalidFrameActionPayloadError,
FrameMessageError,
} from "./errors";

describe("RequestBodyNotJSONError", () => {
it("has correct error message", () => {
const error = new RequestBodyNotJSONError();
expect(error.message).toBe(
"Invalid frame action payload, request body is not JSON"
);
});

it("is instance of Error", () => {
const error = new RequestBodyNotJSONError();
expect(error).toBeInstanceOf(Error);
});
});

describe("InvalidFrameActionPayloadError", () => {
it("has default error message", () => {
const error = new InvalidFrameActionPayloadError();
expect(error.message).toBe("Invalid frame action payload");
});

it("accepts custom error message", () => {
const error = new InvalidFrameActionPayloadError("Custom error message");
expect(error.message).toBe("Custom error message");
});

it("is instance of Error", () => {
const error = new InvalidFrameActionPayloadError();
expect(error).toBeInstanceOf(Error);
});
});

describe("FrameMessageError", () => {
it("creates error with valid message and status", () => {
const error = new FrameMessageError("Test error message", 400);
expect(error.message).toBe("Test error message");
expect(error.status).toBe(400);
});

it("is instance of Error", () => {
const error = new FrameMessageError("Test", 400);
expect(error).toBeInstanceOf(Error);
});

it("throws error for message longer than 90 characters", () => {
const longMessage = "a".repeat(91);
expect(() => new FrameMessageError(longMessage, 400)).toThrow(
"Message too long"
);
});

it("allows message with exactly 90 characters", () => {
const maxMessage = "a".repeat(90);
const error = new FrameMessageError(maxMessage, 400);
expect(error.message).toBe(maxMessage);
});

it("throws error for status code less than 400", () => {
expect(() => new FrameMessageError("Test", 399)).toThrow(
"Invalid status code"
);
expect(() => new FrameMessageError("Test", 200)).toThrow(
"Invalid status code"
);
});

it("throws error for status code 500 or greater", () => {
expect(() => new FrameMessageError("Test", 500)).toThrow(
"Invalid status code"
);
expect(() => new FrameMessageError("Test", 501)).toThrow(
"Invalid status code"
);
});

it("allows all valid 4XX status codes", () => {
expect(() => new FrameMessageError("Test", 400)).not.toThrow();
expect(() => new FrameMessageError("Test", 404)).not.toThrow();
expect(() => new FrameMessageError("Test", 499)).not.toThrow();
});
});
19 changes: 17 additions & 2 deletions packages/frames.js/src/core/errors.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
/**
* Error thrown when the request body cannot be parsed as JSON.
* This typically occurs when the frame action payload is malformed.
*/
export class RequestBodyNotJSONError extends Error {
constructor() {
super("Invalid frame action payload, request body is not JSON");
}
}

/**
* Error thrown when the frame action payload is invalid.
* This occurs when the payload doesn't contain the expected structure.
*/
export class InvalidFrameActionPayloadError extends Error {
constructor(message = "Invalid frame action payload") {
super(message);
}
}

/**
* Error class for frame message validation errors.
* Used to return user-facing error messages with appropriate HTTP status codes.
*/
export class FrameMessageError extends Error {
status: number;

/**
*
* Creates a new FrameMessageError.
* @param message - Message to show the user (up to 90 characters)
* @param status - 4XX status code
* @param status - HTTP status code (must be 4XX)
* @throws Error if message exceeds 90 characters
* @throws Error if status code is not in 4XX range
*/
constructor(message: string, status: number) {
if (message.length > 90) throw new Error("Message too long");
Expand All @@ -26,3 +40,4 @@ export class FrameMessageError extends Error {
this.status = status;
}
}

107 changes: 107 additions & 0 deletions packages/frames.js/src/core/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import {
parseButtonInformationFromTargetURL,
resolveBaseUrl,
generateTargetURL,
joinPaths,
parseSearchParams,
isFrameRedirect,
isFrameDefinition,
} from "./utils";

describe("generateTargetURL", () => {
Expand Down Expand Up @@ -242,3 +246,106 @@ describe("parseButtonInformationFromTargetURL", () => {
});
});
});

describe("joinPaths", () => {
it("returns pathA when pathB is empty string", () => {
expect(joinPaths("/base", "")).toBe("/base");
});

it("returns pathA when pathB is /", () => {
expect(joinPaths("/base", "/")).toBe("/base");
});

it("joins two paths correctly", () => {
expect(joinPaths("/base", "/path")).toBe("/base/path");
});

it("removes duplicate slashes", () => {
expect(joinPaths("/base/", "/path")).toBe("/base/path");
expect(joinPaths("/base//", "//path")).toBe("/base/path");
});

it("handles paths without leading slash", () => {
expect(joinPaths("base", "path")).toBe("base/path");
});
});

describe("parseSearchParams", () => {
it("returns empty object for URL without search params", () => {
const url = new URL("http://test.com");
expect(parseSearchParams(url)).toEqual({ searchParams: {} });
});

it("parses single search param", () => {
const url = new URL("http://test.com?key=value");
expect(parseSearchParams(url)).toEqual({ searchParams: { key: "value" } });
});

it("parses multiple search params", () => {
const url = new URL("http://test.com?a=1&b=2&c=3");
expect(parseSearchParams(url)).toEqual({
searchParams: { a: "1", b: "2", c: "3" },
});
});
});

describe("isFrameRedirect", () => {
it("returns true for valid redirect object", () => {
expect(isFrameRedirect({ kind: "redirect", location: "https://example.com" })).toBe(true);
});

it("returns false for null", () => {
expect(isFrameRedirect(null)).toBe(false);
});

it("returns false for undefined", () => {
expect(isFrameRedirect(undefined)).toBe(false);
});

it("returns false for object without kind property", () => {
expect(isFrameRedirect({ location: "https://example.com" })).toBe(false);
});

it("returns false for object with wrong kind value", () => {
expect(isFrameRedirect({ kind: "other", location: "https://example.com" })).toBe(false);
});

it("returns false for primitive values", () => {
expect(isFrameRedirect("redirect")).toBe(false);
expect(isFrameRedirect(123)).toBe(false);
expect(isFrameRedirect(true)).toBe(false);
});
});

describe("isFrameDefinition", () => {
it("returns true for object with image property", () => {
expect(isFrameDefinition({ image: "https://example.com/image.png" })).toBe(true);
});

it("returns true for complete frame definition", () => {
expect(
isFrameDefinition({
image: "https://example.com/image.png",
buttons: [],
state: { count: 0 },
})
).toBe(true);
});

it("returns false for null", () => {
expect(isFrameDefinition(null)).toBe(false);
});

it("returns false for undefined", () => {
expect(isFrameDefinition(undefined)).toBe(false);
});

it("returns false for object without image property", () => {
expect(isFrameDefinition({ buttons: [] })).toBe(false);
});

it("returns false for primitive values", () => {
expect(isFrameDefinition("image")).toBe(false);
expect(isFrameDefinition(123)).toBe(false);
});
});
Loading