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
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 not shown.
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 not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added ui/apps/docs/public/img/shots/dashboard.png
Binary file added ui/apps/docs/public/img/shots/device-list.png
Binary file added ui/apps/docs/public/img/shots/members.png
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" "$@"
42 changes: 42 additions & 0 deletions ui/apps/docs/src/components/Shot.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
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";

interface Props {
id: string;
route: string;
of?: ShotSelector;
viewport?: ShotViewport;
alt?: string;
edition?: ShotEdition;
before?: ShotInteraction[];
}

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

recordShot({ id, route, of, viewport, edition, before, page: Astro.url.pathname });

warnMissingShotImageInDev(id);
---
<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