Skip to content
Merged
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
3 changes: 2 additions & 1 deletion ui/apps/docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import tailwind from "@astrojs/tailwind";
import react from "@astrojs/react";
import { shots } from "./src/lib/shots/integration.ts";

export default defineConfig({
server: { port: 8083, host: true, allowedHosts: true },
devToolbar: { enabled: false },
integrations: [mdx(), tailwind(), react()],
integrations: [mdx(), tailwind(), react(), shots()],
});
1 change: 1 addition & 0 deletions ui/apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "astro dev",
"build": "astro build",
"shots": "./scripts/capture-shots.sh",
"preview": "astro preview",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
Expand Down
Binary file removed ui/apps/docs/public/img/account/mfa-enable.gif
Binary file not shown.
Binary file removed ui/apps/docs/public/img/account/mfa-login.png
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file removed ui/apps/docs/public/img/account/mfa-setup.png
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file removed ui/apps/docs/public/img/devices/device-add.gif
Binary file not shown.
Binary file removed ui/apps/docs/public/img/devices/device-connect.gif
Binary file not shown.
Binary file not shown.
Binary file removed ui/apps/docs/public/img/devices/device-list.png
Binary file not shown.
Binary file removed ui/apps/docs/public/img/devices/device-pending.gif
Binary file not shown.
Binary file removed ui/apps/docs/public/img/firewall/add-rule.png
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file removed ui/apps/docs/public/img/sessions/session-play.gif
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file removed ui/apps/docs/public/img/team/members.png
Diff not rendered.
Diff not rendered.
35 changes: 35 additions & 0 deletions ui/apps/docs/scripts/capture-shots.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Refresh the screenshots the docs use, by handing shellhub-demo the manifest the last build
# wrote and the directory the pages read from.
#
# The demo deliberately knows neither path: it photographs whatever list it is given and
# writes wherever it is told, which is what keeps a demo-environment builder free of any
# knowledge of the documentation that consumes it. That boundary constrains the demo, not
# the caller - and this side knows both paths perfectly well, so it fills them in.

set -euo pipefail

docs_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
manifest="${docs_dir}/.astro/shots.json"
out_dir="${docs_dir}/public/img/shots"

# Assumed to sit beside the shellhub checkout, which is how the two are normally cloned.
# Overridable because "normally" is not "always", and a wrong guess should be a sentence
# rather than a stack trace.
demo_dir="${SHELLHUB_DEMO_DIR:-${docs_dir}/../../../../shellhub-demo}"

if [ ! -x "${demo_dir}/stage" ]; then
echo "no shellhub-demo checkout at ${demo_dir}" >&2
echo "-> clone it beside shellhub, or set SHELLHUB_DEMO_DIR" >&2
exit 1
fi

# Written at astro:build:done, so a stale manifest means a stale shot list - and a shot
# removed from a page would otherwise keep being photographed forever.
if [ ! -f "$manifest" ]; then
echo "no shot list at ${manifest}" >&2
echo "-> npm run build -w @shellhub/docs first" >&2
exit 1
fi

exec "${demo_dir}/stage" capture --manifest "$manifest" --out "$out_dir" "$@"
63 changes: 31 additions & 32 deletions ui/apps/docs/src/components/Shot.astro
Original file line number Diff line number Diff line change
@@ -1,43 +1,42 @@
---
import fs from "node:fs";
import path from "node:path";
import { warnMissingShotImageInDev } from "../lib/shots/check";
import type {
ShotEdition,
ShotInteraction,
ShotSelector,
ShotViewport,
} from "../lib/shots/manifest";
import { shotImageHref } from "../lib/shots/paths";
import { recordShot } from "../lib/shots/registry";

/**
* A screenshot the docs declare for themselves.
*
* The page states what it wants captured and where the image belongs; the
* capture pass reads these markers and produces exactly them. There is no
* separate manifest to drift out of sync, and the id is the filename, so a
* shot cannot end up written next to the image the prose actually references.
*
* `of`, `expect`, `click` and `padding` are read by the capture pass rather
* than by this component: they describe how to reach and frame the shot.
*/
interface Props {
id: string;
alt: string;
route: string;
of?: { role: string; name?: string };
expect?: string[];
click?: string;
padding?: number;
caption?: string;
of?: ShotSelector;
viewport?: ShotViewport;
alt?: string;
edition?: ShotEdition;
before?: ShotInteraction[];
}

const { id, alt, caption } = Astro.props;
const { id, route, of, viewport, alt, edition, before } = Astro.props;

const src = `/img/generated/${id}.png`;
const file = path.join(process.cwd(), "public", src);
recordShot({ id, route, of, viewport, edition, before, page: Astro.url.pathname });

if (!fs.existsSync(file)) {
throw new Error(
`<Shot id="${id}"> has no image at public${src}. ` +
"Run the capture pass, or remove the marker.",
);
}
warnMissingShotImageInDev(id);
---

<figure class="my-6">
<img src={src} alt={alt} loading="lazy" class="rounded-lg border border-border" />
{caption && <figcaption class="mt-2 text-sm text-text-muted">{caption}</figcaption>}
<figure class="shot">
<img src={shotImageHref(id)} alt={alt ?? id} />
</figure>

<style>
.shot {
margin: 1.5rem 0;
}

.shot img {
display: block;
width: 100%;
border-radius: 0.375rem;
}
</style>
78 changes: 78 additions & 0 deletions ui/apps/docs/src/lib/shots/__tests__/build.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { execFileSync } from "node:child_process";
import { readFileSync, rmSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { JSDOM } from "jsdom";
import { beforeAll, describe, expect, it } from "vitest";
import type { ShotManifest } from "@/lib/shots/manifest";

const fixture = fileURLToPath(
new URL("../../../../test/fixtures/shots-site", import.meta.url),
);

let manifest: ShotManifest;

/**
* The component runs in Vite's SSR module graph and the integration in Node's.
* Those are separate module caches, so a registry that works under vitest can
* still hand the integration an empty array during a real build. Only an actual
* `astro build` exercises both graphs, which is why this one test is worth its
* cost.
*/
describe("shots integration, over a real astro build", () => {
beforeAll(() => {
rmSync(`${fixture}/dist`, { recursive: true, force: true });
rmSync(`${fixture}/.astro`, { recursive: true, force: true });

execFileSync("npx", ["astro", "build", "--root", "."], {
cwd: fixture,
encoding: "utf-8",
env: { ...process.env, CI: "" },
});

manifest = JSON.parse(
readFileSync(`${fixture}/.astro/shots.json`, "utf-8"),
) as ShotManifest;
}, 120_000);

it("emits every declared shot, merging the one used on both pages", () => {
expect(manifest.shots).toEqual([
{
id: "dashboard",
route: "/",
viewport: { width: 800, height: 600 },
edition: "ce",
usedBy: ["/one/"],
},
{
id: "device-list",
route: "/devices",
viewport: { width: 1440, height: 900 },
edition: "ce",
of: { role: "table" },
usedBy: ["/one/", "/two/"],
},
{
id: "session-list",
route: "/sessions",
viewport: { width: 1440, height: 900 },
edition: "enterprise",
usedBy: ["/two/"],
},
]);
});

it("renders the image that displays each shot", () => {
const html = readFileSync(`${fixture}/dist/one/index.html`, "utf-8");
const { document } = new JSDOM(html).window;

const images = [...document.querySelectorAll("img")].map((image) => ({
src: image.getAttribute("src"),
alt: image.getAttribute("alt"),
}));

expect(images).toContainEqual({
src: "/img/shots/device-list.png",
alt: "Devices",
});
});
});
95 changes: 95 additions & 0 deletions ui/apps/docs/src/lib/shots/__tests__/check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { removeImages, unreferencedImages } from "@/lib/shots/check";

let root = "";
const publicDir = () => join(root, "public");
const outDir = () => join(root, "dist");

function image(dir: string, name: string): void {
mkdirSync(join(publicDir(), dir), { recursive: true });
writeFileSync(join(publicDir(), dir, name), "");
}

function page(html: string): void {
mkdirSync(join(outDir(), "guides"), { recursive: true });
writeFileSync(join(outDir(), "guides", "index.html"), html);
}

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "shots-check-"));
});

afterEach(() => {
rmSync(root, { recursive: true, force: true });
});

describe("unreferencedImages", () => {
it("keeps a captured shot the built page displays", () => {
image("img/shots", "device-list.png");
page('<img src="/img/shots/device-list.png">');

expect(unreferencedImages(publicDir(), outDir())).toEqual([]);
});

it("reports a shot no page displays any more", () => {
image("img/shots", "device-list.png");
image("img/shots", "firewall-rules.png");
page('<img src="/img/shots/device-list.png">');

expect(unreferencedImages(publicDir(), outDir())).toEqual([
"img/shots/firewall-rules.png",
]);
});

// Manual images are referenced by plain markdown, which no manifest knows
// about. Reading the built html is what lets one rule cover both directories.
it("reports a manual image no page displays any more", () => {
image("img/manual", "mfa-setup.png");
image("img/manual", "public-keys.png");
page('<img src="/img/manual/mfa-setup.png" alt="setup">');

expect(unreferencedImages(publicDir(), outDir())).toEqual([
"img/manual/public-keys.png",
]);
});

it("counts a reference from anywhere in the output", () => {
image("img/manual", "session-play.gif");
mkdirSync(outDir(), { recursive: true });
writeFileSync(join(outDir(), "styles.css"), "a{background:url(/img/manual/session-play.gif)}");

expect(unreferencedImages(publicDir(), outDir())).toEqual([]);
});

// Every image lives in one of the owned directories, so a build that wrote no
// html would otherwise propose deleting all of them at once.
it("proposes nothing when there is no build to read", () => {
image("img/shots", "device-list.png");

expect(unreferencedImages(publicDir(), join(root, "nowhere"))).toEqual([]);
});

it("ignores directories the docs do not own", () => {
image("img/logos", "shellhub.svg");
page("<p>no images here</p>");

expect(unreferencedImages(publicDir(), outDir())).toEqual([]);
});
});

describe("removeImages", () => {
it("deletes exactly what it was given", () => {
image("img/shots", "device-list.png");
image("img/manual", "public-keys.png");
page('<img src="/img/shots/device-list.png">');

removeImages(publicDir(), unreferencedImages(publicDir(), outDir()));

expect(unreferencedImages(publicDir(), outDir())).toEqual([]);
expect(existsSync(join(publicDir(), "img/shots/device-list.png"))).toBe(true);
expect(existsSync(join(publicDir(), "img/manual/public-keys.png"))).toBe(false);
});
});
Loading
Loading