mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: update tool count to 51, refresh hero copy and login page
- Update stale tool counts (48/49/50) to 51 across all apps, docs, and tests - Add Meme Generator to landing page bento grid (was missing) - Add 12 SEO-friendly rotating phrases to hero typing cursor - Change landing hero subtitle to "Every image tool you need." - Replace outdated login page copy with branded hero + rotating phrases
This commit is contained in:
@@ -39,7 +39,7 @@ function generateLlmsTxt(spec: OpenAPISpec): string {
|
||||
lines.push(`# ${spec.info.title}`);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"> Self-hosted image processing API with 50 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
|
||||
"> Self-hosted image processing API with 51 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("## Docs");
|
||||
|
||||
@@ -43,7 +43,7 @@ Shared TypeScript types, constants (like `APP_VERSION` and tool definitions), an
|
||||
|
||||
### API (`apps/api`)
|
||||
|
||||
A Fastify v5 server exposing 50 tool routes (34 standard image operations + 15 AI-powered + editor) that handles:
|
||||
A Fastify v5 server exposing 51 tool routes (35 standard image operations + 15 AI-powered + editor) that handles:
|
||||
- File uploads, temporary workspace management, and persistent file storage
|
||||
- User file library with version chains (`user_files` table) -- each processed result links back to its source file and records which tool was applied, with auto-generated thumbnails for the Files page
|
||||
- Tool execution (routes each tool request to the image engine or AI bridge)
|
||||
|
||||
@@ -64,7 +64,7 @@ pnpm dev
|
||||
|
||||
## What You Can Do
|
||||
|
||||
### Image Processing (50 Tools)
|
||||
### Image Processing (51 Tools)
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ layout: home
|
||||
hero:
|
||||
name: "SnapOtter"
|
||||
text: "A Self Hosted Image Manipulator"
|
||||
tagline: 50 tools. Local AI. No cloud. Your images never leave your home.
|
||||
tagline: 51 tools. Local AI. No cloud. Your images never leave your home.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Get started
|
||||
@@ -14,7 +14,7 @@ hero:
|
||||
link: /api/rest
|
||||
|
||||
features:
|
||||
- title: 50 Image Tools
|
||||
- title: 51 Image Tools
|
||||
details: Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, build collages, generate passport photos, find duplicates, and more.
|
||||
- title: Local AI
|
||||
details: 15 AI-powered tools - remove backgrounds, upscale, enhance images, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR), fix fake transparency. All on your hardware, no internet required.
|
||||
|
||||
@@ -8,7 +8,7 @@ const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito" });
|
||||
export const metadata: Metadata = {
|
||||
title: "SnapOtter | Self-Hosted Image Processing",
|
||||
description:
|
||||
"48 image processing tools with local AI. Runs 100% offline. No data leaves your network. Open source and free forever.",
|
||||
"51 image processing tools with local AI. Runs 100% offline. No data leaves your network. Open source and free forever.",
|
||||
metadataBase: new URL("https://snapotter.com"),
|
||||
icons: {
|
||||
icon: [
|
||||
@@ -24,7 +24,7 @@ export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
title: "SnapOtter | Self-Hosted Image Processing",
|
||||
description:
|
||||
"48 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
|
||||
"51 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
|
||||
url: "https://snapotter.com",
|
||||
siteName: "SnapOtter",
|
||||
type: "website",
|
||||
@@ -41,7 +41,7 @@ export const metadata: Metadata = {
|
||||
card: "summary_large_image",
|
||||
title: "SnapOtter | Self-Hosted Image Processing",
|
||||
description:
|
||||
"48 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
|
||||
"51 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
|
||||
images: ["/og-image.svg"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Grid3x3,
|
||||
ImagePlus,
|
||||
Info,
|
||||
Laugh,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Maximize2,
|
||||
@@ -275,6 +276,12 @@ const tools: { name: string; description: string; category: string; icon: Lucide
|
||||
category: "watermark",
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
name: "Meme Generator",
|
||||
description: "Create memes with templates and custom text",
|
||||
category: "watermark",
|
||||
icon: Laugh,
|
||||
},
|
||||
// Utilities
|
||||
{
|
||||
name: "Image Info",
|
||||
@@ -408,7 +415,7 @@ export function BentoGrid() {
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<FadeIn>
|
||||
<h2 className="font-[family-name:var(--font-nunito)] text-center text-3xl font-bold tracking-tight md:text-4xl">
|
||||
50 tools. Zero cloud dependency.
|
||||
51 tools. Zero cloud dependency.
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-xl text-center text-lg text-muted">
|
||||
Search to find exactly what you need. Every tool runs 100% locally.
|
||||
|
||||
@@ -119,7 +119,7 @@ export function Hero() {
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 animate-[fadeUp_0.6s_ease-out_0.1s_both] text-lg text-muted md:text-xl">
|
||||
The open-source, self-hosted image processing platform.
|
||||
Every image tool you need.
|
||||
</p>
|
||||
|
||||
<p className="mt-4 animate-[fadeUp_0.6s_ease-out_0.2s_both] text-xl font-medium md:text-2xl">
|
||||
|
||||
@@ -7,7 +7,7 @@ const freePlan = {
|
||||
price: "Free",
|
||||
subtitle: "For everyone. Forever.",
|
||||
features: [
|
||||
"All 48 image processing tools",
|
||||
"All 51 image processing tools",
|
||||
"Unlimited usage, no hidden caps",
|
||||
"Full REST API with OpenAPI docs",
|
||||
"Pipeline automation",
|
||||
|
||||
@@ -11,12 +11,24 @@ const phrases = [
|
||||
"No limits. No hidden caps.",
|
||||
"Works fully offline.",
|
||||
"Unlimited batch processing.",
|
||||
"48 image tools.",
|
||||
"51 image tools.",
|
||||
"15 AI models. Your hardware.",
|
||||
"Lightning fast. Built on Sharp.",
|
||||
"Air-gapped ready.",
|
||||
"One Docker container.",
|
||||
"Open source. Always.",
|
||||
"Every pixel stays on your hardware.",
|
||||
"Your server. Your rules.",
|
||||
"Deploys in a single docker pull.",
|
||||
"The self-hosted alternative.",
|
||||
"Resize, compress, convert. Locally.",
|
||||
"Free image editor. No watermarks.",
|
||||
"Bulk image processing. Unlimited.",
|
||||
"Remove backgrounds offline.",
|
||||
"Self-hosted Canva alternative.",
|
||||
"No API keys needed.",
|
||||
"GDPR compliant by design.",
|
||||
"Works without internet.",
|
||||
];
|
||||
|
||||
export function TypingCursor() {
|
||||
|
||||
@@ -2460,7 +2460,7 @@ function AboutSection() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A self-hosted, privacy-first image processing suite with 50 tools. Resize, compress,
|
||||
A self-hosted, privacy-first image processing suite with 51 tools. Resize, compress,
|
||||
convert, watermark, and automate your image workflows without sending data to the cloud.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
|
||||
@@ -1,6 +1,64 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { type FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import { setToken } from "@/lib/api";
|
||||
|
||||
const phrases = [
|
||||
"No signups. No accounts.",
|
||||
"No uploads to the cloud. Ever.",
|
||||
"100% local processing.",
|
||||
"Free. Forever.",
|
||||
"No limits. No hidden caps.",
|
||||
"Works fully offline.",
|
||||
"Unlimited batch processing.",
|
||||
"51 image tools.",
|
||||
"15 AI models. Your hardware.",
|
||||
"Lightning fast. Built on Sharp.",
|
||||
"Air-gapped ready.",
|
||||
"One Docker container.",
|
||||
"Open source. Always.",
|
||||
"Every pixel stays on your hardware.",
|
||||
"Your server. Your rules.",
|
||||
"Deploys in a single docker pull.",
|
||||
"The self-hosted alternative.",
|
||||
"Resize, compress, convert. Locally.",
|
||||
"Free image editor. No watermarks.",
|
||||
"Bulk image processing. Unlimited.",
|
||||
"Remove backgrounds offline.",
|
||||
"Self-hosted Canva alternative.",
|
||||
"No API keys needed.",
|
||||
"GDPR compliant by design.",
|
||||
"Works without internet.",
|
||||
];
|
||||
|
||||
function RotatingPhrase() {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
const advance = useCallback(() => {
|
||||
setVisible(false);
|
||||
setTimeout(() => {
|
||||
setIndex((i) => (i + 1) % phrases.length);
|
||||
setVisible(true);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(advance, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [advance]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-block transition-all duration-300 text-white/90"
|
||||
style={{
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible ? "translateY(0)" : "translateY(-8px)",
|
||||
}}
|
||||
>
|
||||
{phrases[index]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -94,11 +152,11 @@ export function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden lg:flex flex-1 bg-primary/90 items-center justify-center p-12 text-white rounded-l-3xl">
|
||||
<div className="max-w-lg space-y-6 text-center">
|
||||
<h2 className="text-3xl font-bold">Your one-stop-shop for all your image needs.</h2>
|
||||
<p className="text-lg text-white/80">
|
||||
A privacy-first image suite that lets you resize, compress, convert, and process images
|
||||
with 33+ powerful tools.
|
||||
<div className="max-w-lg space-y-4 text-center">
|
||||
<h2 className="text-4xl font-extrabold tracking-tight">Your images. Stay yours.</h2>
|
||||
<p className="text-lg text-white/70">Every image tool you need.</p>
|
||||
<p className="text-xl font-medium h-8">
|
||||
<RotatingPhrase />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -225,7 +225,7 @@ export const en = {
|
||||
regenerateKey: "Regenerate",
|
||||
copyKey: "Copy Key",
|
||||
aboutDescription:
|
||||
"SnapOtter is a self-hosted, privacy-first image processing suite with 50 tools.",
|
||||
"SnapOtter is a self-hosted, privacy-first image processing suite with 51 tools.",
|
||||
aboutLinks: "Links",
|
||||
github: "GitHub",
|
||||
documentation: "Documentation",
|
||||
|
||||
@@ -13,7 +13,7 @@ test.describe("Docs Homepage", () => {
|
||||
await expect(page.getByText("SnapOtter").first()).toBeVisible();
|
||||
await expect(page.getByText("A Self Hosted Image Manipulator")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("50 tools. Local AI. No cloud. Your images never leave your home."),
|
||||
page.getByText("51 tools. Local AI. No cloud. Your images never leave your home."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ test.describe("Docs Homepage", () => {
|
||||
|
||||
test("features section renders all 6 feature cards", async ({ page }) => {
|
||||
const features = [
|
||||
"48 Image Tools",
|
||||
"51 Image Tools",
|
||||
"Local AI",
|
||||
"Pipelines",
|
||||
"REST API",
|
||||
|
||||
@@ -23,9 +23,7 @@ test.describe("Landing Homepage", () => {
|
||||
|
||||
test("hero section renders headline and subheadline", async ({ page }) => {
|
||||
await expect(page.getByText("Your images. Stay yours.")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("The open-source, self-hosted image processing platform."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Every image tool you need.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("hero CTA links to GitHub", async ({ page }) => {
|
||||
@@ -59,9 +57,9 @@ test.describe("Landing Homepage", () => {
|
||||
});
|
||||
|
||||
test("bento grid renders with search and tool count", async ({ page }) => {
|
||||
await expect(page.getByText("50 tools. Zero cloud dependency.")).toBeVisible();
|
||||
await expect(page.getByText("51 tools. Zero cloud dependency.")).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Search tools...")).toBeVisible();
|
||||
await expect(page.getByText(/Showing 50 of 50 tools/)).toBeVisible();
|
||||
await expect(page.getByText(/Showing 51 of 51 tools/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("enterprise section renders feature cards", async ({ page }) => {
|
||||
|
||||
@@ -9,28 +9,28 @@ test.describe("BentoGrid Interactions", () => {
|
||||
const input = page.getByPlaceholder("Search tools...");
|
||||
await input.fill("resize");
|
||||
await expect(page.getByText("Resize", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(/Showing \d+ of 50 tools/)).toBeVisible();
|
||||
await expect(page.getByText(/Showing \d+ of 51 tools/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("category pill filters tools", async ({ page }) => {
|
||||
await page.getByText(/AI Tools/).click();
|
||||
await expect(page.getByText(/Showing 15 of 50 tools/)).toBeVisible();
|
||||
await expect(page.getByText(/Showing 15 of 51 tools/)).toBeVisible();
|
||||
await expect(page.getByText("Remove Background")).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking All resets category filter", async ({ page }) => {
|
||||
await page.getByText(/AI Tools/).click();
|
||||
await expect(page.getByText(/Showing 15 of 50 tools/)).toBeVisible();
|
||||
await expect(page.getByText(/Showing 15 of 51 tools/)).toBeVisible();
|
||||
|
||||
await page.getByText(/All \(50\)/).click();
|
||||
await expect(page.getByText(/Showing 50 of 50 tools/)).toBeVisible();
|
||||
await page.getByText(/All \(51\)/).click();
|
||||
await expect(page.getByText(/Showing 51 of 51 tools/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("search with no results shows empty state", async ({ page }) => {
|
||||
const input = page.getByPlaceholder("Search tools...");
|
||||
await input.fill("xyznonexistent");
|
||||
await expect(page.getByText("No tools found. Try a different search.")).toBeVisible();
|
||||
await expect(page.getByText(/Showing 0 of 50 tools/)).toBeVisible();
|
||||
await expect(page.getByText(/Showing 0 of 51 tools/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("combined search and category filter works", async ({ page }) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ test.describe("Login Page", () => {
|
||||
await expect(page.getByRole("button", { name: /login/i })).toBeVisible();
|
||||
|
||||
// Right side: marketing text (hidden on mobile, visible on lg+)
|
||||
await expect(page.getByText("Your one-stop-shop")).toBeVisible();
|
||||
await expect(page.getByText("Your images. Stay yours.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("username and password inputs start empty", async ({ page }) => {
|
||||
|
||||
@@ -70,7 +70,7 @@ test.describe("Responsive - Desktop (1280x720)", () => {
|
||||
await page.goto("/login");
|
||||
|
||||
await expect(page.getByRole("heading", { name: /login/i })).toBeVisible();
|
||||
await expect(page.getByText("Your one-stop-shop")).toBeVisible();
|
||||
await expect(page.getByText("Your images. Stay yours.")).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
@@ -405,7 +405,7 @@ test.describe("Responsive - Mobile (375x667)", () => {
|
||||
await expect(page.getByLabel("Username")).toBeVisible();
|
||||
|
||||
// Marketing panel is hidden on mobile (lg:flex means only visible at lg+)
|
||||
await expect(page.getByText("Your one-stop-shop")).not.toBeVisible();
|
||||
await expect(page.getByText("Your images. Stay yours.")).not.toBeVisible();
|
||||
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||
|
||||
@@ -53,7 +53,7 @@ test.describe("Visual Mobile (375x667)", () => {
|
||||
|
||||
// Verify stacked layout: marketing panel should be hidden on mobile
|
||||
await expect(page.getByRole("heading", { name: /login/i })).toBeVisible();
|
||||
await expect(page.getByText("Your one-stop-shop")).not.toBeVisible();
|
||||
await expect(page.getByText("Your images. Stay yours.")).not.toBeVisible();
|
||||
|
||||
await takeThemedScreenshots(page, "login-empty");
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ test.describe("Smoke tests", () => {
|
||||
await expect(page.getByLabel("Password")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /login/i })).toBeVisible();
|
||||
// Right panel marketing text
|
||||
await expect(page.getByText("Your one-stop-shop")).toBeVisible();
|
||||
await expect(page.getByText("Your images. Stay yours.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can log in with admin credentials", async ({ page }) => {
|
||||
|
||||
@@ -47,7 +47,7 @@ function generateLlmsTxt(spec: OpenAPISpec): string {
|
||||
lines.push(`# ${spec.info.title}`);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"> Self-hosted image processing API with 50 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
|
||||
"> Self-hosted image processing API with 51 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("## Docs");
|
||||
|
||||
@@ -25,7 +25,7 @@ function getCountText(): string {
|
||||
describe("BentoGrid", () => {
|
||||
it("renders the section heading", () => {
|
||||
render(<BentoGrid />);
|
||||
expect(screen.getByText("50 tools. Zero cloud dependency.")).toBeDefined();
|
||||
expect(screen.getByText("51 tools. Zero cloud dependency.")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders the search input", () => {
|
||||
@@ -36,12 +36,12 @@ describe("BentoGrid", () => {
|
||||
it("shows all tools by default", () => {
|
||||
render(<BentoGrid />);
|
||||
const text = getCountText();
|
||||
expect(text).toMatch(/Showing 50 of 50 tools/);
|
||||
expect(text).toMatch(/Showing 51 of 51 tools/);
|
||||
});
|
||||
|
||||
it("renders category filter pills including All", () => {
|
||||
render(<BentoGrid />);
|
||||
expect(screen.getByText((_, el) => el?.textContent === "All (50)")).toBeDefined();
|
||||
expect(screen.getByText((_, el) => el?.textContent === "All (51)")).toBeDefined();
|
||||
expect(screen.getByText(/Essentials/)).toBeDefined();
|
||||
expect(screen.getByText(/AI Tools/)).toBeDefined();
|
||||
expect(screen.getByText(/Optimization/)).toBeDefined();
|
||||
@@ -53,7 +53,7 @@ describe("BentoGrid", () => {
|
||||
fireEvent.change(input, { target: { value: "resize" } });
|
||||
expect(screen.getByText("Resize")).toBeDefined();
|
||||
const text = getCountText();
|
||||
expect(text).toMatch(/Showing \d+ of 50 tools/);
|
||||
expect(text).toMatch(/Showing \d+ of 51 tools/);
|
||||
expect(screen.queryByText("OCR / Text Extraction")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("BentoGrid", () => {
|
||||
const aiButton = screen.getByText(/AI Tools/);
|
||||
fireEvent.click(aiButton);
|
||||
const text = getCountText();
|
||||
expect(text).toMatch(/Showing 15 of 50 tools/);
|
||||
expect(text).toMatch(/Showing 15 of 51 tools/);
|
||||
expect(screen.getByText("Remove Background")).toBeDefined();
|
||||
expect(screen.queryByText("Resize")).toBeNull();
|
||||
});
|
||||
@@ -73,7 +73,7 @@ describe("BentoGrid", () => {
|
||||
fireEvent.change(input, { target: { value: "xyznonexistent" } });
|
||||
expect(screen.getByText("No tools found. Try a different search.")).toBeDefined();
|
||||
const text = getCountText();
|
||||
expect(text).toMatch(/Showing 0 of 50 tools/);
|
||||
expect(text).toMatch(/Showing 0 of 51 tools/);
|
||||
});
|
||||
|
||||
it("combines search and category filter", () => {
|
||||
@@ -89,9 +89,9 @@ describe("BentoGrid", () => {
|
||||
it("clicking All resets category filter", () => {
|
||||
render(<BentoGrid />);
|
||||
fireEvent.click(screen.getByText(/AI Tools/));
|
||||
expect(getCountText()).toMatch(/Showing 15 of 50 tools/);
|
||||
fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (50)"));
|
||||
expect(getCountText()).toMatch(/Showing 50 of 50 tools/);
|
||||
expect(getCountText()).toMatch(/Showing 15 of 51 tools/);
|
||||
fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (51)"));
|
||||
expect(getCountText()).toMatch(/Showing 51 of 51 tools/);
|
||||
});
|
||||
|
||||
it("renders each tool with name and description", () => {
|
||||
|
||||
@@ -41,9 +41,7 @@ describe("Hero", () => {
|
||||
|
||||
it("renders the subheadline", () => {
|
||||
render(<Hero />);
|
||||
expect(
|
||||
screen.getByText("The open-source, self-hosted image processing platform."),
|
||||
).toBeDefined();
|
||||
expect(screen.getByText("Every image tool you need.")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders the CTA button linking to GitHub", () => {
|
||||
@@ -159,7 +157,7 @@ describe("Pricing", () => {
|
||||
|
||||
it("renders free plan features", () => {
|
||||
render(<Pricing />);
|
||||
expect(screen.getByText("All 48 image processing tools")).toBeDefined();
|
||||
expect(screen.getByText("All 51 image processing tools")).toBeDefined();
|
||||
expect(screen.getByText("Unlimited usage, no hidden caps")).toBeDefined();
|
||||
expect(screen.getByText("15 local AI models included")).toBeDefined();
|
||||
expect(screen.getByText("AGPL-3.0 licensed")).toBeDefined();
|
||||
|
||||
Reference in New Issue
Block a user