Files
coder/site/src/hooks/useEmbeddedMetadata.test.ts
T

270 lines
7.2 KiB
TypeScript

import { act, renderHook } from "@testing-library/react";
import type { Region, User } from "api/typesGenerated";
import {
MockAppearanceConfig,
MockBuildInfo,
MockEntitlements,
MockExperiments,
MockUser,
} from "testHelpers/entities";
import {
DEFAULT_METADATA_KEY,
type MetadataKey,
MetadataManager,
type MetadataValue,
type RuntimeHtmlMetadata,
makeUseEmbeddedMetadata,
useEmbeddedMetadata,
} from "./useEmbeddedMetadata";
// Make sure that no matter what happens in the tests, all metadata is
// eventually deleted
const allAppendedNodes = new Set<Set<Element>>();
afterAll(() => {
for (const tracker of allAppendedNodes) {
for (const node of tracker) {
node.remove();
}
}
});
// Using empty array for now, because we don't have a separate mock regions
// value, but it's still good enough for the tests because it's truthy
const MockRegions: readonly Region[] = [];
const mockDataForTags = {
appearance: MockAppearanceConfig,
"build-info": MockBuildInfo,
entitlements: MockEntitlements,
experiments: MockExperiments,
user: MockUser,
regions: MockRegions,
} as const satisfies Record<MetadataKey, MetadataValue>;
const emptyMetadata: RuntimeHtmlMetadata = {
appearance: {
available: false,
value: undefined,
},
"build-info": {
available: false,
value: undefined,
},
entitlements: {
available: false,
value: undefined,
},
experiments: {
available: false,
value: undefined,
},
regions: {
available: false,
value: undefined,
},
user: {
available: false,
value: undefined,
},
};
const populatedMetadata: RuntimeHtmlMetadata = {
appearance: {
available: true,
value: MockAppearanceConfig,
},
"build-info": {
available: true,
value: MockBuildInfo,
},
entitlements: {
available: true,
value: MockEntitlements,
},
experiments: {
available: true,
value: MockExperiments,
},
regions: {
available: true,
value: MockRegions,
},
user: {
available: true,
value: MockUser,
},
};
function seedInitialMetadata(metadataKey: string): () => void {
// Enforcing this to make sure that even if we start to adopt more concurrent
// tests through Vitest (or similar), there's no risk of global state causing
// weird, hard-to-test false positives/negatives with other tests
if (metadataKey === DEFAULT_METADATA_KEY) {
throw new Error(
"Please ensure that the key you provide does not match the key used throughout the majority of the application",
);
}
const trackedNodes = new Set<Element>();
allAppendedNodes.add(trackedNodes);
for (const metadataName in mockDataForTags) {
// Serializing first because that's the part that can fail; want to fail
// early if possible
const value = mockDataForTags[metadataName as keyof typeof mockDataForTags];
const serialized = JSON.stringify(value);
const newNode = document.createElement("meta");
newNode.setAttribute(metadataKey, metadataName);
newNode.setAttribute("content", serialized);
document.head.append(newNode);
trackedNodes.add(newNode);
}
return () => {
for (const node of trackedNodes) {
node.remove();
}
};
}
function renderMetadataHook(metadataKey: string) {
const manager = new MetadataManager(metadataKey);
const hook = makeUseEmbeddedMetadata(manager);
return {
...renderHook(hook),
manager,
};
}
// Just to be on the safe side, probably want to make sure that each test case
// is set up with a unique key
describe(useEmbeddedMetadata.name, () => {
it("Correctly detects when metadata is missing in the HTML page", () => {
const key = "cat";
// Deliberately avoid seeding any metadata
const { result } = renderMetadataHook(key);
expect(result.current.metadata).toEqual<RuntimeHtmlMetadata>(emptyMetadata);
});
it("Can detect when metadata exists in the HTML", () => {
const key = "dog";
const cleanupTags = seedInitialMetadata(key);
const { result } = renderMetadataHook(key);
expect(result.current.metadata).toEqual<RuntimeHtmlMetadata>(
populatedMetadata,
);
cleanupTags();
});
it("Lets external systems (including React) subscribe to when metadata values are deleted", () => {
const key = "bird";
const tag1: MetadataKey = "user";
const tag2: MetadataKey = "appearance";
const cleanupTags = seedInitialMetadata(key);
const { result: reactResult, manager } = renderMetadataHook(key);
const nonReactSubscriber = jest.fn();
manager.subscribe(nonReactSubscriber);
const expectedUpdate1: RuntimeHtmlMetadata = {
...populatedMetadata,
[tag1]: {
available: false,
value: undefined,
},
};
// Test that updates work when run directly through the metadata manager
// itself
act(() => manager.clearMetadataByKey(tag1));
expect(reactResult.current.metadata).toEqual(expectedUpdate1);
expect(nonReactSubscriber).toBeCalledWith(expectedUpdate1);
nonReactSubscriber.mockClear();
const expectedUpdate2: RuntimeHtmlMetadata = {
...expectedUpdate1,
[tag2]: {
available: false,
value: undefined,
},
};
// Test that updates work when calling the convenience function exposed
// through the React hooks
act(() => reactResult.current.clearMetadataByKey(tag2));
expect(reactResult.current.metadata).toEqual(expectedUpdate2);
expect(nonReactSubscriber).toBeCalledWith(expectedUpdate2);
cleanupTags();
});
it("Will delete the original metadata node when you try deleting a metadata state value", () => {
const key = "aardvark";
const tagToDelete: MetadataKey = "user";
const cleanupTags = seedInitialMetadata(key);
const { result } = renderMetadataHook(key);
const query = `meta[${key}=${tagToDelete}]`;
let userNode = document.querySelector(query);
expect(userNode).not.toBeNull();
act(() => result.current.clearMetadataByKey(tagToDelete));
userNode = document.querySelector(query);
expect(userNode).toBeNull();
cleanupTags();
});
it("Will not notify subscribers if you try deleting a metadata value that does not exist (or has already been deleted)", () => {
const key = "giraffe";
const tagToDelete: MetadataKey = "entitlements";
const cleanupTags = seedInitialMetadata(key);
const { result } = renderMetadataHook(key);
act(() => result.current.clearMetadataByKey(tagToDelete));
const resultBeforeNoOp = result.current.metadata;
act(() => result.current.clearMetadataByKey(tagToDelete));
expect(result.current.metadata).toBe(resultBeforeNoOp);
cleanupTags();
});
// Need to guarantee this, or else we could have a good number of bugs in the
// React UI
it("Always treats metadata as immutable values during all deletions", () => {
const key = "hamster";
const tagToDelete: MetadataKey = "user";
const cleanupTags = seedInitialMetadata(key);
const { result } = renderMetadataHook(key);
const initialResult = result.current.metadata;
act(() => result.current.clearMetadataByKey(tagToDelete));
const newResult = result.current.metadata;
expect(initialResult).not.toBe(newResult);
// Mutate the initial result, and make sure the change doesn't propagate to
// the updated result
const mutableUser = initialResult.user as {
available: boolean;
value: User | undefined;
};
mutableUser.available = false;
mutableUser.value = undefined;
expect(mutableUser).toEqual(newResult.user);
expect(mutableUser).not.toBe(newResult.user);
cleanupTags();
});
});