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
158 changes: 158 additions & 0 deletions src/routes/Import/Import.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

import { ImportPage } from "./index";

const mockNavigate = vi.fn();
let mockSearchParams: { url?: string } = {
url: "http://127.0.0.1:9999/pipeline.yaml",
};

vi.mock("@tanstack/react-router", async (importOriginal) => {
return {
...(await importOriginal()),
useNavigate: () => mockNavigate,
useSearch: () => mockSearchParams,
useRouter: () => ({ navigate: vi.fn() }),
};
});

vi.mock("@/services/pipelineService", () => ({
importPipelineFromYaml: vi.fn(),
}));

import { importPipelineFromYaml } from "@/services/pipelineService";

describe("ImportPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearchParams = { url: "http://127.0.0.1:9999/pipeline.yaml" };
globalThis.fetch = vi.fn();
});

afterEach(() => {
cleanup();
});

test("shows step indicator with all steps on initial render", () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockReturnValue(
new Promise(() => {}),
);
render(<ImportPage />);
expect(screen.getByText("Importing Pipeline")).toBeInTheDocument();
expect(screen.getByText("Fetching pipeline")).toBeInTheDocument();
expect(screen.getByText("Importing into editor")).toBeInTheDocument();
expect(screen.getByText("Opening editor")).toBeInTheDocument();
});

test("progresses through steps and redirects on success", async () => {
const yamlContent = "name: Test Pipeline\nimplementation: {}";
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
text: () => Promise.resolve(yamlContent),
});
(importPipelineFromYaml as ReturnType<typeof vi.fn>).mockResolvedValue({
successful: true,
name: "Test Pipeline",
});

render(<ImportPage />);

await waitFor(() => {
expect(importPipelineFromYaml).toHaveBeenCalledWith(yamlContent, true);
});

await waitFor(() => {
expect(screen.getByText(/Test Pipeline/)).toBeInTheDocument();
});

await waitFor(
() => {
expect(mockNavigate).toHaveBeenCalledWith({
to: "/editor/Test%20Pipeline",
});
},
{ timeout: 5000 },
);
});

test("shows error UI when fetch returns non-OK response", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 404,
statusText: "Not Found",
});

render(<ImportPage />);

await waitFor(() => {
expect(screen.getByText("Import Failed")).toBeInTheDocument();
expect(
screen.getByText(/Something went wrong importing the pipeline/),
).toBeInTheDocument();
expect(
screen.getByText(/Failed to fetch pipeline: 404 Not Found/),
).toBeInTheDocument();
expect(screen.getByText("← Back to Home")).toBeInTheDocument();
});
});

test("shows error when import returns failure", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
text: () => Promise.resolve("invalid: yaml"),
});
(importPipelineFromYaml as ReturnType<typeof vi.fn>).mockResolvedValue({
successful: false,
name: "",
errorMessage: "Invalid pipeline format",
});

render(<ImportPage />);

await waitFor(() => {
expect(screen.getByText("Import Failed")).toBeInTheDocument();
expect(screen.getByText(/Invalid pipeline format/)).toBeInTheDocument();
});
});

test("shows error for missing url parameter", async () => {
mockSearchParams = {};

render(<ImportPage />);

await waitFor(() => {
expect(screen.getByText("Import Failed")).toBeInTheDocument();
expect(screen.getByText(/Missing 'url' parameter/)).toBeInTheDocument();
});
});

test("shows error when fetched content is empty", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
text: () => Promise.resolve(" "),
});

render(<ImportPage />);

await waitFor(() => {
expect(screen.getByText("Import Failed")).toBeInTheDocument();
expect(
screen.getByText(/The fetched pipeline content is empty/),
).toBeInTheDocument();
});
});

test("shows error when fetch throws a network error", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Network failure"),
);

render(<ImportPage />);

await waitFor(() => {
expect(screen.getByText("Import Failed")).toBeInTheDocument();
expect(screen.getByText(/Network failure/)).toBeInTheDocument();
});
});
});
210 changes: 210 additions & 0 deletions src/routes/Import/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { useNavigate, useRouter, useSearch } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";

import { InfoBox } from "@/components/shared/InfoBox";
import { Button } from "@/components/ui/button";
import { BlockStack } from "@/components/ui/layout";
import { Spinner } from "@/components/ui/spinner";
import { Paragraph, Text } from "@/components/ui/typography";
import { EDITOR_PATH } from "@/routes/router";
import { importPipelineFromYaml } from "@/services/pipelineService";

type ImportSearch = { url?: string };

/**
* Import route that fetches a pipeline YAML from a URL and imports it into the editor.
*
* Used by tangle-deploy CLI's `tangle-view-pipeline` command, which starts a temporary
* local HTTP server and opens: /#/import?url=http://localhost:PORT/pipeline.yaml
*
* Flow:
* 1. Read `url` from search params
* 2. Fetch YAML from the URL
* 3. Import into IndexedDB via importPipelineFromYaml (overwrite if same name exists)
* 4. Redirect to the editor
*/
enum Step {
Fetching = "fetching",
Importing = "importing",
Done = "done",
}

const STEPS: { key: Step; label: string; emoji: string }[] = [
{ key: Step.Fetching, label: "Fetching pipeline", emoji: "📡" },
{ key: Step.Importing, label: "Importing into editor", emoji: "📦" },
{ key: Step.Done, label: "Opening editor", emoji: "🚀" },
];

const StepIndicator = ({
currentStep,
pipelineName,
}: {
currentStep: Step;
pipelineName: string | null;
}) => {
const currentIndex = STEPS.findIndex((s) => s.key === currentStep);

return (
<div className="flex flex-col gap-3 w-full">
{STEPS.map((step, i) => {
const isActive = i === currentIndex;
const isComplete = i < currentIndex;
const isPending = i > currentIndex;

return (
<div
key={step.key}
className={`flex items-center gap-3 rounded-lg px-4 py-3 transition-all duration-300 ${
isActive
? "bg-primary/10 border border-primary/20"
: isComplete
? "bg-muted/50"
: "opacity-40"
}`}
>
<span className="text-lg w-7 text-center">
{isComplete ? "✅" : isActive ? step.emoji : "⏳"}
</span>
<div className="flex-1 min-w-0">
<Paragraph
size="sm"
weight={isActive ? "semibold" : "regular"}
tone={isPending ? "subdued" : undefined}
>
{step.label}
{step.key === Step.Done && pipelineName && (
<span className="font-mono ml-1">
&ldquo;{pipelineName}&rdquo;
</span>
)}
</Paragraph>
</div>
{isActive && <Spinner size={16} />}
</div>
);
})}
</div>
);
};

export const ImportPage = () => {
const search = useSearch({ strict: false }) as ImportSearch;
const navigate = useNavigate();
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [step, setStep] = useState<Step>(Step.Fetching);
const [pipelineName, setPipelineName] = useState<string | null>(null);
const importedRef = useRef(false);

useEffect(() => {
// React Strict Mode re-mounts components, firing useEffect twice.
// useRef persists across remounts to prevent a duplicate import.
if (importedRef.current) return;

const importFromUrl = async () => {
const url = search.url;

if (!url) {
setError("Missing 'url' parameter. Nothing to import.");
return;
}

try {
setStep(Step.Fetching);
const response = await fetch(url);

if (!response.ok) {
setError(
`Failed to fetch pipeline: ${response.status} ${response.statusText}`,
);
return;
}

const yamlContent = await response.text();

if (!yamlContent || yamlContent.trim().length === 0) {
setError("The fetched pipeline content is empty.");
return;
}

setStep(Step.Importing);
const result = await importPipelineFromYaml(yamlContent, true);

if (result.successful) {
importedRef.current = true;
setPipelineName(result.name);
setStep(Step.Done);
// TODO: remove this delay -- temporary for debugging
await new Promise((r) => setTimeout(r, 3000));
navigate({
to: `${EDITOR_PATH}/${encodeURIComponent(result.name)}`,
});
} else {
setError(
result.errorMessage || "Failed to import pipeline from URL.",
);
}
} catch (err) {
setError(
`Failed to import pipeline: ${err instanceof Error ? err.message : String(err)}`,
);
}
};

importFromUrl();
}, [search.url, navigate]);

if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="max-w-sm w-full">
<BlockStack gap="6" align="center">
<div className="text-center">
<span className="text-4xl mb-3 block">❌</span>
<Text size="xl" weight="bold">
Import Failed
</Text>
<Paragraph tone="subdued" size="sm" className="mt-1">
Something went wrong importing the pipeline.
</Paragraph>
</div>

<InfoBox title="Error Details" variant="error">
<Paragraph font="mono" size="xs">
{error}
</Paragraph>
</InfoBox>

<Button
onClick={() => router.navigate({ to: "/" })}
variant="secondary"
className="w-full"
>
← Back to Home
</Button>
</BlockStack>
</div>
</div>
);
}

return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="max-w-sm w-full">
<BlockStack gap="6" align="center">
<div className="text-center">
<span className="text-4xl mb-3 block">🔧</span>
<Text size="xl" weight="bold">
Importing Pipeline
</Text>
<Paragraph tone="subdued" size="sm" className="mt-1">
Setting up your pipeline in the editor...
</Paragraph>
</div>

<StepIndicator currentStep={step} pipelineName={pipelineName} />
</BlockStack>
</div>
</div>
);
};
Loading
Loading