mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge 04d5c53e20 into 3f7b9667c6
This commit is contained in:
commit
7ec222b137
6 changed files with 199 additions and 22 deletions
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
|
@ -29,5 +29,13 @@ jobs:
|
|||
- name: Run TypeScript type checking
|
||||
run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph'
|
||||
|
||||
- name: Install Raycast extension dependencies
|
||||
working-directory: apps/raycast-extension
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Test Raycast extension
|
||||
working-directory: apps/raycast-extension
|
||||
run: npm test
|
||||
|
||||
- name: Run Biome CI (format & lint on changed files)
|
||||
run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
"typescript": "^5.8.2"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test tests",
|
||||
"build": "ray build",
|
||||
"dev": "ray develop",
|
||||
"fix-lint": "ray lint --fix",
|
||||
|
|
|
|||
|
|
@ -214,9 +214,3 @@ export async function searchMemories(
|
|||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if API key is configured and valid
|
||||
export async function fetchSettings(): Promise<object> {
|
||||
const response = await makeAuthenticatedRequest<object>("/v3/settings")
|
||||
return response
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,15 +11,15 @@ import { fetchProjects, addProject } from "./api"
|
|||
import {
|
||||
FormValidation,
|
||||
showFailureToast,
|
||||
useCachedPromise,
|
||||
useForm,
|
||||
usePromise,
|
||||
} from "@raycast/utils"
|
||||
import { withSupermemory } from "./withSupermemory"
|
||||
|
||||
export default withSupermemory(Command)
|
||||
|
||||
function Command() {
|
||||
const { isLoading, data: projects, mutate } = useCachedPromise(fetchProjects)
|
||||
const { isLoading, data: projects, mutate } = usePromise(fetchProjects)
|
||||
|
||||
return (
|
||||
<List isLoading={isLoading} searchBarPlaceholder="Search your projects">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { usePromise } from "@raycast/utils"
|
||||
import { fetchSettings } from "./api"
|
||||
import {
|
||||
Action,
|
||||
ActionPanel,
|
||||
Detail,
|
||||
getPreferenceValues,
|
||||
Icon,
|
||||
List,
|
||||
openExtensionPreferences,
|
||||
|
|
@ -12,18 +10,11 @@ import type { ComponentType } from "react"
|
|||
|
||||
export function withSupermemory<P extends object>(Component: ComponentType<P>) {
|
||||
return function SupermemoryWrappedComponent(props: P) {
|
||||
const { isLoading, data } = usePromise(fetchSettings, [], {
|
||||
failureToastOptions: {
|
||||
title: "Invalid API Key",
|
||||
message:
|
||||
"Invalid API key. Please check your API key in preferences. Get a new one from https://supermemory.link/raycast",
|
||||
},
|
||||
})
|
||||
// Let each command enforce its own endpoint-specific permissions and rate limit.
|
||||
const { apiKey } = getPreferenceValues<Preferences>()
|
||||
|
||||
if (!data) {
|
||||
return isLoading ? (
|
||||
<Detail isLoading />
|
||||
) : (
|
||||
if (!apiKey.trim()) {
|
||||
return (
|
||||
<List>
|
||||
<List.EmptyView
|
||||
icon={Icon.ExclamationMark}
|
||||
|
|
|
|||
183
apps/raycast-extension/tests/api.test.ts
Normal file
183
apps/raycast-extension/tests/api.test.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from "bun:test"
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
let apiKey = "scoped-key"
|
||||
|
||||
function RaycastComponent() {
|
||||
return null
|
||||
}
|
||||
|
||||
const Action = Object.assign(RaycastComponent, {
|
||||
Push: RaycastComponent,
|
||||
SubmitForm: RaycastComponent,
|
||||
})
|
||||
const Form = Object.assign(RaycastComponent, {
|
||||
TextField: RaycastComponent,
|
||||
})
|
||||
const List = Object.assign(RaycastComponent, {
|
||||
EmptyView: RaycastComponent,
|
||||
})
|
||||
const mutate = mock(async () => undefined)
|
||||
const useCachedPromise = mock(() => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
mutate,
|
||||
}))
|
||||
const usePromise = mock(() => ({ data: [], isLoading: false, mutate }))
|
||||
|
||||
mock.module("@raycast/api", () => ({
|
||||
Action,
|
||||
ActionPanel: RaycastComponent,
|
||||
Detail: RaycastComponent,
|
||||
Form,
|
||||
getPreferenceValues: () => ({ apiKey }),
|
||||
Icon: {
|
||||
ExclamationMark: "exclamation-mark",
|
||||
Gear: "gear",
|
||||
},
|
||||
List,
|
||||
openExtensionPreferences: () => undefined,
|
||||
showToast: async () => undefined,
|
||||
Toast: {
|
||||
Style: {
|
||||
Failure: "failure",
|
||||
Success: "success",
|
||||
},
|
||||
},
|
||||
useNavigation: () => ({ pop: () => undefined }),
|
||||
}))
|
||||
|
||||
mock.module("@raycast/utils", () => ({
|
||||
FormValidation: { Required: "required" },
|
||||
showFailureToast: async () => undefined,
|
||||
useCachedPromise,
|
||||
useForm: () => ({ handleSubmit: () => undefined, itemProps: {} }),
|
||||
usePromise,
|
||||
}))
|
||||
|
||||
const apiModule = import("../src/api")
|
||||
const wrapperModule = import("../src/withSupermemory")
|
||||
const searchProjectsModule = import("../src/search-projects")
|
||||
|
||||
function installScopedKeyContract({ searchStatus = 200 } = {}) {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const url = String(input)
|
||||
requests.push({ url, init })
|
||||
|
||||
if (url.endsWith("/v3/settings")) {
|
||||
return Response.json(
|
||||
{ message: "Endpoint not allowed for scoped API key" },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
if (url.endsWith("/v3/search")) {
|
||||
if (searchStatus !== 200) {
|
||||
return Response.json({ message: "Invalid API key" }, { status: 401 })
|
||||
}
|
||||
return Response.json({ results: [], timing: 0, total: 0 })
|
||||
}
|
||||
|
||||
return Response.json({ message: "Unexpected endpoint" }, { status: 404 })
|
||||
}) as typeof fetch
|
||||
return requests
|
||||
}
|
||||
|
||||
describe("Raycast scoped API keys", () => {
|
||||
beforeEach(() => {
|
||||
apiKey = "scoped-key"
|
||||
useCachedPromise.mockClear()
|
||||
usePromise.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
test.serial(
|
||||
"renders a configured command without a remote preflight",
|
||||
async () => {
|
||||
const fetchMock = mock(() => {
|
||||
throw new Error("The wrapper must not make an API request")
|
||||
})
|
||||
globalThis.fetch = fetchMock as typeof fetch
|
||||
const { withSupermemory } = await wrapperModule
|
||||
function Command() {
|
||||
return null
|
||||
}
|
||||
|
||||
const rendered = withSupermemory(Command)({})
|
||||
|
||||
expect(rendered.type).toBe(Command)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(usePromise).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
test.serial("keeps the preferences prompt for a missing key", async () => {
|
||||
apiKey = " "
|
||||
const { withSupermemory } = await wrapperModule
|
||||
function Command() {
|
||||
return null
|
||||
}
|
||||
|
||||
const rendered = withSupermemory(Command)({})
|
||||
|
||||
expect(rendered.type).toBe(List)
|
||||
})
|
||||
|
||||
test.serial(
|
||||
"does not reuse project data cached under another key",
|
||||
async () => {
|
||||
const { default: WrappedSearchProjects } = await searchProjectsModule
|
||||
const wrappedResult = WrappedSearchProjects({})
|
||||
const SearchProjects = wrappedResult.type as () => unknown
|
||||
|
||||
SearchProjects()
|
||||
|
||||
expect(usePromise).toHaveBeenCalledTimes(1)
|
||||
expect(useCachedPromise).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
test.serial(
|
||||
"searches with a scoped key without probing settings",
|
||||
async () => {
|
||||
const requests = installScopedKeyContract()
|
||||
const { searchMemories } = await apiModule
|
||||
|
||||
await expect(searchMemories({ q: "planning" })).resolves.toEqual([])
|
||||
expect(requests.map(({ url }) => new URL(url).pathname)).toEqual([
|
||||
"/v3/search",
|
||||
])
|
||||
expect(requests[0]?.init?.headers).toMatchObject({
|
||||
Authorization: "Bearer scoped-key",
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test.serial(
|
||||
"still rejects an invalid key during the requested operation",
|
||||
async () => {
|
||||
installScopedKeyContract({ searchStatus: 401 })
|
||||
const { searchMemories } = await apiModule
|
||||
|
||||
await expect(searchMemories({ q: "planning" })).rejects.toThrow(
|
||||
"Invalid API key",
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
mock.restore()
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue