diff --git a/packages/base/avif-image-def.gts b/packages/base/avif-image-def.gts new file mode 100644 index 00000000000..9d5f007dc88 --- /dev/null +++ b/packages/base/avif-image-def.gts @@ -0,0 +1,28 @@ +import { readFirstBytes } from '@cardstack/runtime-common'; +import { ImageDef } from './image-file-def'; +import { type ByteStream, type SerializedFile } from './file-api'; +import { extractAvifDimensions } from './avif-meta-extractor'; + +// The AVIF ispe box is typically within the first few KB, but can follow +// other ISOBMFF boxes. 64 KB covers virtually all real-world files. +const AVIF_MAX_HEADER_BYTES = 65_536; + +export class AvifDef extends ImageDef { + static displayName = 'AVIF Image'; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { contentHash?: string } = {}, + ): Promise> { + let base = await super.extractAttributes(url, getStream, options); + let bytes = await readFirstBytes(await getStream(), AVIF_MAX_HEADER_BYTES); + let { width, height } = extractAvifDimensions(bytes); + + return { + ...base, + width, + height, + }; + } +} diff --git a/packages/base/avif-meta-extractor.ts b/packages/base/avif-meta-extractor.ts new file mode 100644 index 00000000000..6fdc914627e --- /dev/null +++ b/packages/base/avif-meta-extractor.ts @@ -0,0 +1,164 @@ +import { FileContentMismatchError } from './file-api'; + +// AVIF uses ISO Base Media File Format (ISOBMFF). +// The file starts with a "ftyp" box whose brand is "avif" or "avis". +// Image dimensions live in an "ispe" box at: meta > iprp > ipco > ispe. +const FTYP_MARKER = new Uint8Array([0x66, 0x74, 0x79, 0x70]); // "ftyp" +const AVIF_BRAND = new Uint8Array([0x61, 0x76, 0x69, 0x66]); // "avif" +const AVIS_BRAND = new Uint8Array([0x61, 0x76, 0x69, 0x73]); // "avis" + +// Minimum: ftyp box header (8) + major brand (4) = 12 bytes +const MIN_BYTES = 12; + +function matchBytes( + bytes: Uint8Array, + offset: number, + pattern: Uint8Array, +): boolean { + if (offset + pattern.length > bytes.length) { + return false; + } + for (let i = 0; i < pattern.length; i++) { + if (bytes[offset + i] !== pattern[i]) { + return false; + } + } + return true; +} + +function readBoxType(bytes: Uint8Array, offset: number): string { + return String.fromCharCode( + bytes[offset]!, + bytes[offset + 1]!, + bytes[offset + 2]!, + bytes[offset + 3]!, + ); +} + +// Walk sibling boxes within a region and return the offset range of the first +// box matching `targetType`, or undefined if not found. +function findBox( + view: DataView, + bytes: Uint8Array, + start: number, + end: number, + targetType: string, +): { start: number; end: number } | undefined { + let offset = start; + while (offset + 8 <= end) { + let size = view.getUint32(offset); + // size == 0 means the box extends to end of data + let boxEnd = size === 0 ? end : offset + size; + if (size !== 0 && size < 8) { + break; // invalid box size + } + if (boxEnd > end) { + break; + } + let type = readBoxType(bytes, offset + 4); + if (type === targetType) { + return { start: offset, end: boxEnd }; + } + offset = boxEnd; + } + return undefined; +} + +function validateAvifSignature(bytes: Uint8Array): void { + if (bytes.length < MIN_BYTES) { + throw new FileContentMismatchError( + 'File is too small to be a valid AVIF image', + ); + } + + // ftyp box: [size: 4] ["ftyp": 4] [major_brand: 4] [minor_version: 4] [compatible_brands...] + if (!matchBytes(bytes, 4, FTYP_MARKER)) { + throw new FileContentMismatchError( + 'File does not have a valid AVIF signature', + ); + } + + let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let ftypSize = view.getUint32(0); + if (ftypSize < MIN_BYTES || ftypSize > bytes.length) { + ftypSize = Math.min(bytes.length, 64); + } + + // Check major brand (offset 8) and compatible brands (offset 16+) for "avif" or "avis" + let hasAvifBrand = false; + + if (matchBytes(bytes, 8, AVIF_BRAND) || matchBytes(bytes, 8, AVIS_BRAND)) { + hasAvifBrand = true; + } + + if (!hasAvifBrand) { + for (let offset = 16; offset + 4 <= ftypSize; offset += 4) { + if ( + matchBytes(bytes, offset, AVIF_BRAND) || + matchBytes(bytes, offset, AVIS_BRAND) + ) { + hasAvifBrand = true; + break; + } + } + } + + if (!hasAvifBrand) { + throw new FileContentMismatchError('File does not have a valid AVIF brand'); + } +} + +export function extractAvifDimensions(bytes: Uint8Array): { + width: number; + height: number; +} { + validateAvifSignature(bytes); + + let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + // Walk the ISOBMFF box tree: top-level → meta → iprp → ipco → ispe + let meta = findBox(view, bytes, 0, bytes.length, 'meta'); + if (!meta) { + throw new FileContentMismatchError( + 'AVIF file does not contain a meta box', + ); + } + + // meta is a "full box": 8-byte header + 4-byte version/flags before children + let iprp = findBox(view, bytes, meta.start + 12, meta.end, 'iprp'); + if (!iprp) { + throw new FileContentMismatchError( + 'AVIF file does not contain an iprp box', + ); + } + + let ipco = findBox(view, bytes, iprp.start + 8, iprp.end, 'ipco'); + if (!ipco) { + throw new FileContentMismatchError( + 'AVIF file does not contain an ipco box', + ); + } + + let ispe = findBox(view, bytes, ipco.start + 8, ipco.end, 'ispe'); + if (!ispe) { + throw new FileContentMismatchError( + 'AVIF file does not contain image dimensions (ispe box not found)', + ); + } + + // ispe: [size:4] [type:4] [version+flags:4] [width:4] [height:4] = 20 bytes + if (ispe.end - ispe.start < 20) { + throw new FileContentMismatchError('AVIF ispe box is truncated'); + } + + let width = view.getUint32(ispe.start + 12); + let height = view.getUint32(ispe.start + 16); + + if (width === 0 || height === 0) { + throw new FileContentMismatchError( + 'AVIF ispe box contains zero dimensions', + ); + } + + return { width, height }; +} diff --git a/packages/base/gif-image-def.gts b/packages/base/gif-image-def.gts new file mode 100644 index 00000000000..ae35e3a2ee7 --- /dev/null +++ b/packages/base/gif-image-def.gts @@ -0,0 +1,24 @@ +import { readFirstBytes } from '@cardstack/runtime-common'; +import { ImageDef } from './image-file-def'; +import { type ByteStream, type SerializedFile } from './file-api'; +import { extractGifDimensions } from './gif-meta-extractor'; + +export class GifDef extends ImageDef { + static displayName = 'GIF Image'; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { contentHash?: string } = {}, + ): Promise> { + let base = await super.extractAttributes(url, getStream, options); + let bytes = await readFirstBytes(await getStream(), 10); + let { width, height } = extractGifDimensions(bytes); + + return { + ...base, + width, + height, + }; + } +} diff --git a/packages/base/gif-meta-extractor.ts b/packages/base/gif-meta-extractor.ts new file mode 100644 index 00000000000..50557981b2c --- /dev/null +++ b/packages/base/gif-meta-extractor.ts @@ -0,0 +1,45 @@ +import { FileContentMismatchError } from './file-api'; + +// GIF files start with either "GIF87a" or "GIF89a" (6 bytes) +const GIF87A_SIGNATURE = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]); +const GIF89A_SIGNATURE = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); + +// Minimum bytes needed: 6 (signature) + 4 (width + height) +const MIN_BYTES = 10; + +function validateGifSignature(bytes: Uint8Array): void { + if (bytes.length < 6) { + throw new FileContentMismatchError( + 'File is too small to be a valid GIF image', + ); + } + + let isGif87a = GIF87A_SIGNATURE.every((b, i) => bytes[i] === b); + let isGif89a = GIF89A_SIGNATURE.every((b, i) => bytes[i] === b); + + if (!isGif87a && !isGif89a) { + throw new FileContentMismatchError( + 'File does not have a valid GIF signature', + ); + } +} + +export function extractGifDimensions(bytes: Uint8Array): { + width: number; + height: number; +} { + validateGifSignature(bytes); + + if (bytes.length < MIN_BYTES) { + throw new FileContentMismatchError( + 'GIF file is too small to contain image dimensions', + ); + } + + // Width is at bytes 6-7, height at 8-9 (little-endian uint16) + let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let width = view.getUint16(6, true); + let height = view.getUint16(8, true); + + return { width, height }; +} diff --git a/packages/base/image-file-def.gts b/packages/base/image-file-def.gts new file mode 100644 index 00000000000..e061ad7747c --- /dev/null +++ b/packages/base/image-file-def.gts @@ -0,0 +1,207 @@ +import NumberField from './number'; +import { BaseDefComponent, Component, contains, field } from './card-api'; +import { FileDef } from './file-api'; + +class Isolated extends Component { + +} + +class Atom extends Component { + +} + +class Embedded extends Component { + +} + +class Fitted extends Component { + get backgroundImageStyle() { + if (this.args.model.url) { + return `background-image: url(${this.args.model.url});`; + } + return undefined; + } + + +} + +export class ImageDef extends FileDef { + static displayName = 'Image'; + + @field width = contains(NumberField); + @field height = contains(NumberField); + + static isolated: BaseDefComponent = Isolated; + static embedded: BaseDefComponent = Embedded; + static atom: BaseDefComponent = Atom; + static fitted: BaseDefComponent = Fitted; +} diff --git a/packages/base/jpg-image-def.gts b/packages/base/jpg-image-def.gts new file mode 100644 index 00000000000..3baa60c9b2a --- /dev/null +++ b/packages/base/jpg-image-def.gts @@ -0,0 +1,28 @@ +import { readFirstBytes } from '@cardstack/runtime-common'; +import { ImageDef } from './image-file-def'; +import { type ByteStream, type SerializedFile } from './file-api'; +import { extractJpgDimensions } from './jpg-meta-extractor'; + +// JPEG SOF marker is typically within the first few KB, but can follow +// large EXIF/ICC segments. 64 KB covers virtually all real-world files. +const JPEG_MAX_HEADER_BYTES = 65_536; + +export class JpgDef extends ImageDef { + static displayName = 'JPEG Image'; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { contentHash?: string } = {}, + ): Promise> { + let base = await super.extractAttributes(url, getStream, options); + let bytes = await readFirstBytes(await getStream(), JPEG_MAX_HEADER_BYTES); + let { width, height } = extractJpgDimensions(bytes); + + return { + ...base, + width, + height, + }; + } +} diff --git a/packages/base/jpg-meta-extractor.ts b/packages/base/jpg-meta-extractor.ts new file mode 100644 index 00000000000..908ede714fb --- /dev/null +++ b/packages/base/jpg-meta-extractor.ts @@ -0,0 +1,109 @@ +import { FileContentMismatchError } from './file-api'; + +// JPEG files start with SOI marker: FF D8 +const JPEG_SOI = new Uint8Array([0xff, 0xd8]); + +// Minimum bytes: 2 (SOI) + 2 (marker) + 2 (length) + 5 (precision + height + width) +const MIN_BYTES = 11; + +// SOF markers that contain image dimensions +const SOF_MARKERS = new Set([ + 0xc0, // SOF0 — Baseline DCT + 0xc1, // SOF1 — Extended Sequential DCT + 0xc2, // SOF2 — Progressive DCT + 0xc3, // SOF3 — Lossless (Sequential) + 0xc5, // SOF5 — Differential Sequential DCT + 0xc6, // SOF6 — Differential Progressive DCT + 0xc7, // SOF7 — Differential Lossless (Sequential) + 0xc9, // SOF9 — Extended Sequential DCT (Arithmetic) + 0xca, // SOF10 — Progressive DCT (Arithmetic) + 0xcb, // SOF11 — Lossless (Sequential) (Arithmetic) + 0xcd, // SOF13 — Differential Sequential DCT (Arithmetic) + 0xce, // SOF14 — Differential Progressive DCT (Arithmetic) + 0xcf, // SOF15 — Differential Lossless (Arithmetic) +]); + +function validateJpegSignature(bytes: Uint8Array): void { + if (bytes.length < JPEG_SOI.length) { + throw new FileContentMismatchError( + 'File is too small to be a valid JPEG image', + ); + } + if (bytes[0] !== JPEG_SOI[0] || bytes[1] !== JPEG_SOI[1]) { + throw new FileContentMismatchError( + 'File does not have a valid JPEG signature', + ); + } +} + +export function extractJpgDimensions(bytes: Uint8Array): { + width: number; + height: number; +} { + validateJpegSignature(bytes); + + if (bytes.length < MIN_BYTES) { + throw new FileContentMismatchError( + 'JPEG file is too small to contain frame dimensions', + ); + } + + let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + // Scan for SOF marker starting after SOI (offset 2) + let offset = 2; + while (offset < bytes.length - 1) { + // Each marker starts with 0xFF + if (bytes[offset] !== 0xff) { + throw new FileContentMismatchError( + 'JPEG file has invalid marker structure', + ); + } + + // Skip padding 0xFF bytes + while (offset < bytes.length - 1 && bytes[offset + 1] === 0xff) { + offset++; + } + + let marker = bytes[offset + 1]!; + offset += 2; + + // SOS (Start of Scan) — no more markers with length fields follow + if (marker === 0xda) { + break; + } + + // Markers without a length field (standalone markers) + if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { + continue; + } + + // Read segment length (includes the 2 length bytes but not the marker) + if (offset + 2 > bytes.length) { + break; + } + let segmentLength = view.getUint16(offset); + + if (SOF_MARKERS.has(marker)) { + // SOF segment layout after length: + // 1 byte — precision + // 2 bytes — height (big-endian) + // 2 bytes — width (big-endian) + if (offset + 2 + 5 > bytes.length) { + throw new FileContentMismatchError( + 'JPEG SOF segment is truncated', + ); + } + let height = view.getUint16(offset + 2 + 1); + let width = view.getUint16(offset + 2 + 3); + return { width, height }; + } + + // Skip to next marker + offset += segmentLength; + } + + throw new FileContentMismatchError( + 'JPEG file does not contain a SOF marker with image dimensions', + ); +} diff --git a/packages/base/png-image-def.gts b/packages/base/png-image-def.gts new file mode 100644 index 00000000000..11a5b31b103 --- /dev/null +++ b/packages/base/png-image-def.gts @@ -0,0 +1,24 @@ +import { readFirstBytes } from '@cardstack/runtime-common'; +import { ImageDef } from './image-file-def'; +import { type ByteStream, type SerializedFile } from './file-api'; +import { extractPngDimensions } from './png-meta-extractor'; + +export class PngDef extends ImageDef { + static displayName = 'PNG Image'; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { contentHash?: string } = {}, + ): Promise> { + let base = await super.extractAttributes(url, getStream, options); + let bytes = await readFirstBytes(await getStream(), 24); + let { width, height } = extractPngDimensions(bytes); + + return { + ...base, + width, + height, + }; + } +} diff --git a/packages/base/png-meta-extractor.ts b/packages/base/png-meta-extractor.ts new file mode 100644 index 00000000000..08d476eac30 --- /dev/null +++ b/packages/base/png-meta-extractor.ts @@ -0,0 +1,42 @@ +import { FileContentMismatchError } from './file-api'; + +// PNG 8-byte magic signature +const PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + +// Minimum bytes needed: 8 (signature) + 8 (IHDR chunk header) + 8 (width + height) +const MIN_BYTES = 24; + +function validatePngSignature(bytes: Uint8Array): void { + if (bytes.length < PNG_SIGNATURE.length) { + throw new FileContentMismatchError( + 'File is too small to be a valid PNG image', + ); + } + for (let i = 0; i < PNG_SIGNATURE.length; i++) { + if (bytes[i] !== PNG_SIGNATURE[i]) { + throw new FileContentMismatchError( + 'File does not have a valid PNG signature', + ); + } + } +} + +export function extractPngDimensions(bytes: Uint8Array): { + width: number; + height: number; +} { + validatePngSignature(bytes); + + if (bytes.length < MIN_BYTES) { + throw new FileContentMismatchError( + 'PNG file is too small to contain IHDR chunk', + ); + } + + // Width is at bytes 16-19, height at 20-23 (big-endian uint32) + let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let width = view.getUint32(16); + let height = view.getUint32(20); + + return { width, height }; +} diff --git a/packages/base/svg-image-def.gts b/packages/base/svg-image-def.gts new file mode 100644 index 00000000000..d7fd1ce04b6 --- /dev/null +++ b/packages/base/svg-image-def.gts @@ -0,0 +1,24 @@ +import { byteStreamToUint8Array } from '@cardstack/runtime-common'; +import { ImageDef } from './image-file-def'; +import { type ByteStream, type SerializedFile } from './file-api'; +import { extractSvgDimensions } from './svg-meta-extractor'; + +export class SvgDef extends ImageDef { + static displayName = 'SVG Image'; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { contentHash?: string } = {}, + ): Promise> { + let base = await super.extractAttributes(url, getStream, options); + let bytes = await byteStreamToUint8Array(await getStream()); + let { width, height } = extractSvgDimensions(bytes); + + return { + ...base, + width, + height, + }; + } +} diff --git a/packages/base/svg-meta-extractor.ts b/packages/base/svg-meta-extractor.ts new file mode 100644 index 00000000000..1cc30337582 --- /dev/null +++ b/packages/base/svg-meta-extractor.ts @@ -0,0 +1,58 @@ +import { FileContentMismatchError } from './file-api'; + +/** + * Extract width and height from an SVG file's bytes. + * + * Looks for explicit `width`/`height` attributes on the root `` element + * first, then falls back to the `viewBox` attribute. Only absolute numeric + * values (with optional "px" suffix) are accepted for width/height attributes; + * percentage or other relative units are ignored in favour of viewBox. + */ +export function extractSvgDimensions(bytes: Uint8Array): { + width: number; + height: number; +} { + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + throw new FileContentMismatchError( + 'File cannot be decoded as UTF-8 text', + ); + } + + // Find the opening tag (case-insensitive, may span multiple lines) + let svgTagMatch = text.match(/]*>/is); + if (!svgTagMatch) { + throw new FileContentMismatchError( + 'File does not contain an SVG root element', + ); + } + let svgTag = svgTagMatch[0]; + + // Try explicit width/height attributes first (numeric values, optional "px") + let widthAttr = svgTag.match(/\bwidth\s*=\s*["']?\s*(\d+(?:\.\d+)?)\s*(?:px)?\s*["']?/i); + let heightAttr = svgTag.match(/\bheight\s*=\s*["']?\s*(\d+(?:\.\d+)?)\s*(?:px)?\s*["']?/i); + + if (widthAttr && heightAttr) { + return { + width: Math.round(parseFloat(widthAttr[1]!)), + height: Math.round(parseFloat(heightAttr[1]!)), + }; + } + + // Fall back to viewBox="minX minY width height" + let viewBoxAttr = svgTag.match( + /\bviewBox\s*=\s*["']\s*[\d.]+[\s,]+[\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*["']/i, + ); + if (viewBoxAttr) { + return { + width: Math.round(parseFloat(viewBoxAttr[1]!)), + height: Math.round(parseFloat(viewBoxAttr[2]!)), + }; + } + + throw new FileContentMismatchError( + 'SVG does not specify width/height or viewBox dimensions', + ); +} diff --git a/packages/base/webp-image-def.gts b/packages/base/webp-image-def.gts new file mode 100644 index 00000000000..f79564ba791 --- /dev/null +++ b/packages/base/webp-image-def.gts @@ -0,0 +1,24 @@ +import { readFirstBytes } from '@cardstack/runtime-common'; +import { ImageDef } from './image-file-def'; +import { type ByteStream, type SerializedFile } from './file-api'; +import { extractWebpDimensions } from './webp-meta-extractor'; + +export class WebpDef extends ImageDef { + static displayName = 'WebP Image'; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { contentHash?: string } = {}, + ): Promise> { + let base = await super.extractAttributes(url, getStream, options); + let bytes = await readFirstBytes(await getStream(), 30); + let { width, height } = extractWebpDimensions(bytes); + + return { + ...base, + width, + height, + }; + } +} diff --git a/packages/base/webp-meta-extractor.ts b/packages/base/webp-meta-extractor.ts new file mode 100644 index 00000000000..63034e7d3a4 --- /dev/null +++ b/packages/base/webp-meta-extractor.ts @@ -0,0 +1,108 @@ +import { FileContentMismatchError } from './file-api'; + +// WebP files start with "RIFF" (4 bytes) + file size (4 bytes) + "WEBP" (4 bytes) +const RIFF_SIGNATURE = new Uint8Array([0x52, 0x49, 0x46, 0x46]); // "RIFF" +const WEBP_SIGNATURE = new Uint8Array([0x57, 0x45, 0x42, 0x50]); // "WEBP" + +// Minimum bytes: 12 (RIFF header) + 4 (chunk FourCC) + enough for dimensions +const MIN_BYTES = 30; + +function validateWebpSignature(bytes: Uint8Array): void { + if (bytes.length < 12) { + throw new FileContentMismatchError( + 'File is too small to be a valid WebP image', + ); + } + let isRiff = RIFF_SIGNATURE.every((b, i) => bytes[i] === b); + let isWebp = WEBP_SIGNATURE.every((b, i) => bytes[i + 8] === b); + + if (!isRiff || !isWebp) { + throw new FileContentMismatchError( + 'File does not have a valid WebP signature', + ); + } +} + +export function extractWebpDimensions(bytes: Uint8Array): { + width: number; + height: number; +} { + validateWebpSignature(bytes); + + if (bytes.length < MIN_BYTES) { + throw new FileContentMismatchError( + 'WebP file is too small to contain image dimensions', + ); + } + + // Chunk FourCC starts at byte 12 + let chunkFourCC = String.fromCharCode( + bytes[12]!, + bytes[13]!, + bytes[14]!, + bytes[15]!, + ); + + let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + if (chunkFourCC === 'VP8 ') { + // Lossy WebP — VP8 bitstream + // Frame tag starts at offset 20 (after RIFF header + "VP8 " + chunk size) + // Bytes 20-22: frame tag (3 bytes) + // Bytes 23-25: start code 0x9D 0x01 0x2A + // Bytes 26-27: width (little-endian, lower 14 bits) + // Bytes 28-29: height (little-endian, lower 14 bits) + if (bytes.length < 30) { + throw new FileContentMismatchError( + 'VP8 chunk is too small to contain dimensions', + ); + } + let width = view.getUint16(26, true) & 0x3fff; + let height = view.getUint16(28, true) & 0x3fff; + return { width, height }; + } + + if (chunkFourCC === 'VP8L') { + // Lossless WebP + // Byte 21: signature byte 0x2F + // Bytes 21-24: bitstream header containing width and height + // Width: bits 0-13 of the 32-bit LE value at offset 21 (after signature byte) + // Height: bits 14-27 + if (bytes.length < 25) { + throw new FileContentMismatchError( + 'VP8L chunk is too small to contain dimensions', + ); + } + let signature = bytes[20]; + if (signature !== 0x2f) { + throw new FileContentMismatchError( + 'VP8L chunk has invalid signature byte', + ); + } + let bits = view.getUint32(21, true); + let width = (bits & 0x3fff) + 1; + let height = ((bits >> 14) & 0x3fff) + 1; + return { width, height }; + } + + if (chunkFourCC === 'VP8X') { + // Extended WebP — VP8X chunk contains canvas dimensions + // Bytes 20-23: flags (4 bytes) + // Bytes 24-26: canvas width minus one (24-bit LE) + // Bytes 27-29: canvas height minus one (24-bit LE) + if (bytes.length < 30) { + throw new FileContentMismatchError( + 'VP8X chunk is too small to contain dimensions', + ); + } + let width = + (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16)) + 1; + let height = + (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16)) + 1; + return { width, height }; + } + + throw new FileContentMismatchError( + `WebP file has unrecognized chunk type: ${chunkFourCC}`, + ); +} diff --git a/packages/experiments-realm/PngDefPlayground/ae41b3ed-512e-4978-8925-1aad8a3d3d93.json b/packages/experiments-realm/PngDefPlayground/ae41b3ed-512e-4978-8925-1aad8a3d3d93.json new file mode 100644 index 00000000000..e030c52e50f --- /dev/null +++ b/packages/experiments-realm/PngDefPlayground/ae41b3ed-512e-4978-8925-1aad8a3d3d93.json @@ -0,0 +1,53 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "Image Gallery Demo", + "description": "Showcasing ImageDef/PngDef with various images from the experiments realm" + }, + "relationships": { + "featuredImage": { + "links": { + "self": "./mango.png" + }, + "data": { + "type": "file-meta", + "id": "./mango.png" + } + }, + "gallery.0": { + "links": { + "self": "./mango.png" + }, + "data": { + "type": "file-meta", + "id": "./mango.png" + } + }, + "gallery.1": { + "links": { + "self": "../logo.png" + }, + "data": { + "type": "file-meta", + "id": "../logo.png" + } + }, + "gallery.2": { + "links": { + "self": "../FileLinksExample/mango.png" + }, + "data": { + "type": "file-meta", + "id": "../FileLinksExample/mango.png" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../png-def-playground", + "name": "PngDefPlayground" + } + } + } +} diff --git a/packages/experiments-realm/PngDefPlayground/mango-demo.json b/packages/experiments-realm/PngDefPlayground/mango-demo.json new file mode 100644 index 00000000000..e735492f7ec --- /dev/null +++ b/packages/experiments-realm/PngDefPlayground/mango-demo.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "PNG Image Playground", + "description": "Demonstrating PngDef capabilities including automatic dimension extraction and various display formats" + }, + "relationships": { + "featuredImage": { + "links": { + "self": "./mango.png" + }, + "data": { + "type": "file-meta", + "id": "./mango.png" + } + }, + "gallery.0": { + "links": { + "self": "./mango.png" + }, + "data": { + "type": "file-meta", + "id": "./mango.png" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../png-def-playground", + "name": "PngDefPlayground" + } + } + } +} diff --git a/packages/experiments-realm/PngDefPlayground/mango.png b/packages/experiments-realm/PngDefPlayground/mango.png new file mode 100644 index 00000000000..0e1efd55f34 Binary files /dev/null and b/packages/experiments-realm/PngDefPlayground/mango.png differ diff --git a/packages/experiments-realm/png-def-playground.gts b/packages/experiments-realm/png-def-playground.gts new file mode 100644 index 00000000000..c4da4d77f2a --- /dev/null +++ b/packages/experiments-realm/png-def-playground.gts @@ -0,0 +1,249 @@ +import { + CardDef, + Component, + StringField, + contains, + field, + linksTo, + linksToMany, +} from 'https://cardstack.com/base/card-api'; +import { gt } from '@cardstack/boxel-ui/helpers'; +import { ImageDef } from 'https://cardstack.com/base/image-file-def'; + +/** + * Playground card for demonstrating ImageDef/PngDef capabilities. + * + * This card shows how ImageDef (and its subclass PngDef): + * - Automatically extracts image dimensions (width, height) + * - Renders images in different formats (isolated, embedded, atom, fitted) + * - Works with linksTo and linksToMany fields + */ +export class PngDefPlayground extends CardDef { + static displayName = 'Image Def Playground'; + + @field title = contains(StringField); + @field description = contains(StringField); + + // Single image link (accepts ImageDef or PngDef) + @field featuredImage = linksTo(ImageDef); + + // Multiple image links + @field gallery = linksToMany(ImageDef); + + static isolated = class Isolated extends Component { +

{{@model.title}}

+ {{#if @model.description}} +

{{@model.description}}

+ {{/if}} + + + + +
+

Format Comparison

+ {{#if @model.featuredImage}} +
+
+

Embedded

+
+ <@fields.featuredImage @format='embedded' /> +
+
+ +
+

Atom

+
+ <@fields.featuredImage @format='atom' /> +
+
+ +
+

Fitted

+
+ <@fields.featuredImage @format='fitted' /> +
+
+
+ {{else}} +

Link a featured image to see format + comparison

+ {{/if}} +
+ + + + + + + }; +} diff --git a/packages/host/public/test-realm-sw.js b/packages/host/public/test-realm-sw.js new file mode 100644 index 00000000000..c02e6b76e5c --- /dev/null +++ b/packages/host/public/test-realm-sw.js @@ -0,0 +1,37 @@ +// Service worker that relays requests to test-realm URLs back to the main +// thread so the VirtualNetwork can serve them. Only registered during tests. + +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('fetch', (event) => { + if (!event.request.url.startsWith('http://test-realm/')) { + return; + } + event.respondWith(relayToClient(event)); +}); + +async function relayToClient(event) { + // event.clientId may be empty for cross-origin subresource requests + let client = await self.clients.get(event.clientId); + if (!client) { + let allClients = await self.clients.matchAll({ type: 'window' }); + client = allClients[0]; + } + if (!client) { + return new Response('No client available', { status: 503 }); + } + + return new Promise((resolve) => { + let channel = new MessageChannel(); + channel.port1.onmessage = (msg) => { + let { status, headers, body } = msg.data; + resolve(new Response(body, { status, headers })); + }; + client.postMessage({ type: 'test-realm-fetch', url: event.request.url }, [ + channel.port2, + ]); + }); +} diff --git a/packages/host/tests/acceptance/image-def/avif-image-def-test.gts b/packages/host/tests/acceptance/image-def/avif-image-def-test.gts new file mode 100644 index 00000000000..0857827ea67 --- /dev/null +++ b/packages/host/tests/acceptance/image-def/avif-image-def-test.gts @@ -0,0 +1,383 @@ +import { visit, waitUntil } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; + +import { module, test } from 'qunit'; + +import { + baseRealm, + type FileExtractResponse, + type RenderRouteOptions, + type ResolvedCodeRef, + SupportedMimeType, +} from '@cardstack/runtime-common'; +import type { Realm } from '@cardstack/runtime-common/realm'; + +import type NetworkService from '@cardstack/host/services/network'; + +import { + setupLocalIndexing, + setupOnSave, + testRealmURL, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + capturePrerenderResult, +} from '../../helpers'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupApplicationTest } from '../../helpers/setup'; +import { setupTestRealmServiceWorker } from '../../helpers/test-realm-service-worker'; + +// Build a minimal valid AVIF file (ISOBMFF) with the given dimensions. +// Structure: ftyp box + meta box containing iprp > ipco > ispe (width/height). +// This is sufficient for dimension extraction but not for browser rendering +// (no AV1 pixel data). +function makeMinimalAvif(width: number, height: number): Uint8Array { + let buf = new ArrayBuffer(68); + let view = new DataView(buf); + let bytes = new Uint8Array(buf); + let offset = 0; + + function setChars(o: number, str: string) { + for (let i = 0; i < str.length; i++) bytes[o + i] = str.charCodeAt(i); + } + + // ftyp box (20 bytes) + view.setUint32(offset, 20); + setChars(offset + 4, 'ftyp'); + setChars(offset + 8, 'avif'); + view.setUint32(offset + 12, 0); + setChars(offset + 16, 'avif'); + offset += 20; + + // meta box — fullbox (48 bytes) + view.setUint32(offset, 48); + setChars(offset + 4, 'meta'); + view.setUint32(offset + 8, 0); // version + flags + offset += 12; + + // iprp box (36 bytes) + view.setUint32(offset, 36); + setChars(offset + 4, 'iprp'); + offset += 8; + + // ipco box (28 bytes) + view.setUint32(offset, 28); + setChars(offset + 4, 'ipco'); + offset += 8; + + // ispe box (20 bytes) + view.setUint32(offset, 20); + setChars(offset + 4, 'ispe'); + view.setUint32(offset + 8, 0); // version + flags + view.setUint32(offset + 12, width); + view.setUint32(offset + 16, height); + + return bytes; +} + +// Generate a browser-renderable AVIF using the Canvas API. Chrome's AVIF +// encoder may use a different ISOBMFF layout than our extraction code expects, +// so this is only used for the authenticated-display test (which needs a +// decodable image) while extraction tests use makeMinimalAvif. +async function makeRenderableAvif( + width: number, + height: number, +): Promise { + let canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + let ctx = canvas.getContext('2d')!; + ctx.fillStyle = 'red'; + ctx.fillRect(0, 0, width, height); + let blob: Blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (b) => (b ? resolve(b) : reject(new Error('toBlob returned null'))), + 'image/avif', + ); + }); + return new Uint8Array(await blob.arrayBuffer()); +} + +module('Acceptance | avif image def', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + setupTestRealmServiceWorker(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + }); + let realm: Realm; + + const fileExtractPath = ( + url: string, + renderOptions: RenderRouteOptions, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/file-extract`; + + const fileRenderPath = ( + url: string, + renderOptions: RenderRouteOptions, + format = 'isolated', + ancestorLevel = 0, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/html/${format}/${ancestorLevel}`; + + const makeFileURL = (path: string) => new URL(path, testRealmURL).href; + + const avifDefCodeRef = (): ResolvedCodeRef => ({ + module: `${baseRealm.url}avif-image-def`, + name: 'AvifDef', + }); + + async function captureFileExtractResult( + expectedStatus?: 'ready' | 'error', + ): Promise { + await waitUntil( + () => { + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + return false; + } + let status = container.getAttribute( + 'data-prerender-file-extract-status', + ); + if (!status) { + return false; + } + if (expectedStatus && status !== expectedStatus) { + return false; + } + return status === 'ready' || status === 'error'; + }, + { timeout: 5000 }, + ); + + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + throw new Error( + 'captureFileExtractResult: missing [data-prerender-file-extract] container after wait', + ); + } + let pre = container.querySelector('pre'); + let text = pre?.textContent?.trim() ?? ''; + return JSON.parse(text) as FileExtractResponse; + } + + hooks.beforeEach(async function () { + let renderableAvif = await makeRenderableAvif(2, 3); + ({ realm } = await setupAcceptanceTestRealm({ + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + 'sample.avif': makeMinimalAvif(2, 3), + 'renderable.avif': renderableAvif, + 'not-an-avif.avif': 'This is plain text, not an AVIF file.', + }, + })); + }); + + hooks.afterEach(function () { + delete (globalThis as any).__renderModel; + delete (globalThis as any).__boxelFileRenderData; + }); + + test('extracts width and height from AVIF', async function (assert) { + let url = makeFileURL('sample.avif'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: avifDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.strictEqual(result.searchDoc?.width, 2, 'extracts AVIF width'); + assert.strictEqual(result.searchDoc?.height, 3, 'extracts AVIF height'); + assert.strictEqual(result.searchDoc?.name, 'sample.avif'); + assert.ok( + String(result.searchDoc?.contentType).includes('avif'), + 'sets avif content type', + ); + }); + + test('falls back when AvifDef is used for non-AVIF content', async function (assert) { + let url = makeFileURL('not-an-avif.avif'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: avifDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.true( + result.mismatch, + 'marks mismatch when content is not valid AVIF', + ); + assert.strictEqual(result.searchDoc?.name, 'not-an-avif.avif'); + }); + + test('isolated template renders img with width and height attributes', async function (assert) { + let url = makeFileURL('sample.avif'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: avifDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: avifDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: avifDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.strictEqual( + img?.getAttribute('width'), + '2', + 'img has correct width attribute', + ); + assert.strictEqual( + img?.getAttribute('height'), + '3', + 'img has correct height attribute', + ); + assert.ok( + img?.getAttribute('src')?.includes('sample.avif'), + 'img src references the AVIF file', + ); + }); + + test('indexing stores AVIF metadata and file meta uses it', async function (assert) { + let fileURL = new URL('sample.avif', testRealmURL); + let fileEntry = await realm.realmIndexQueryEngine.file(fileURL); + + assert.ok(fileEntry, 'file entry exists'); + assert.strictEqual( + fileEntry?.searchDoc?.width, + 2, + 'index stores AVIF width', + ); + assert.strictEqual( + fileEntry?.searchDoc?.height, + 3, + 'index stores AVIF height', + ); + + let network = getService('network') as NetworkService; + let response = await network.virtualNetwork.fetch(fileURL, { + headers: { Accept: SupportedMimeType.FileMeta }, + }); + + assert.true(response.ok, 'file meta request succeeds'); + + let body = await response.json(); + assert.strictEqual(body?.data?.type, 'file-meta'); + assert.ok( + String(body?.data?.attributes?.contentType).includes('avif'), + 'file meta uses avif content type', + ); + assert.strictEqual( + body?.data?.attributes?.width, + 2, + 'file meta includes AVIF width', + ); + assert.strictEqual( + body?.data?.attributes?.height, + 3, + 'file meta includes AVIF height', + ); + assert.deepEqual( + body?.data?.meta?.adoptsFrom, + avifDefCodeRef(), + 'file meta uses AVIF def', + ); + }); + + test('authenticated images display in browser', async function (assert) { + // Use renderable.avif (canvas-generated, browser-decodable) rather than + // sample.avif (minimal ISOBMFF, not decodable). + let url = makeFileURL('renderable.avif'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: avifDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: avifDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: avifDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] .image-isolated__img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.ok( + img?.getAttribute('src')?.includes('renderable.avif'), + 'img src references the AVIF file', + ); + + await waitUntil(() => img!.naturalWidth > 0, { + timeout: 5000, + timeoutMessage: + 'Image failed to load - naturalWidth remained 0. This likely indicates an authentication issue preventing the browser from fetching the image.', + }); + + assert.ok( + img!.naturalWidth > 0, + 'Image loaded successfully with non-zero width', + ); + assert.ok( + img!.naturalHeight > 0, + 'Image loaded successfully with non-zero height', + ); + }); +}); diff --git a/packages/host/tests/acceptance/image-def/gif-image-def-test.gts b/packages/host/tests/acceptance/image-def/gif-image-def-test.gts new file mode 100644 index 00000000000..eb0774cf159 --- /dev/null +++ b/packages/host/tests/acceptance/image-def/gif-image-def-test.gts @@ -0,0 +1,336 @@ +import { visit, waitUntil } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; + +import { module, test } from 'qunit'; + +import { + baseRealm, + type FileExtractResponse, + type RenderRouteOptions, + type ResolvedCodeRef, + SupportedMimeType, +} from '@cardstack/runtime-common'; +import type { Realm } from '@cardstack/runtime-common/realm'; + +import type NetworkService from '@cardstack/host/services/network'; + +import { + setupLocalIndexing, + setupOnSave, + testRealmURL, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + capturePrerenderResult, +} from '../../helpers'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupApplicationTest } from '../../helpers/setup'; +import { setupTestRealmServiceWorker } from '../../helpers/test-realm-service-worker'; + +// Build a minimal valid GIF89a with specified dimensions. +// GIF structure: signature (6) + logical screen descriptor (7) + trailer (1) +function makeMinimalGif(width: number, height: number): Uint8Array { + let parts: number[] = []; + + // GIF89a signature + parts.push(0x47, 0x49, 0x46, 0x38, 0x39, 0x61); + + // Logical Screen Descriptor (7 bytes) + // Width (little-endian uint16) + parts.push(width & 0xff, (width >> 8) & 0xff); + // Height (little-endian uint16) + parts.push(height & 0xff, (height >> 8) & 0xff); + // Packed field: no global color table, color resolution=1, not sorted, GCT size=0 + parts.push(0x00); + // Background color index + parts.push(0x00); + // Pixel aspect ratio + parts.push(0x00); + + // GIF Trailer + parts.push(0x3b); + + return new Uint8Array(parts); +} + +module('Acceptance | gif image def', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + setupTestRealmServiceWorker(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + }); + let realm: Realm; + + const fileExtractPath = ( + url: string, + renderOptions: RenderRouteOptions, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/file-extract`; + + const fileRenderPath = ( + url: string, + renderOptions: RenderRouteOptions, + format = 'isolated', + ancestorLevel = 0, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/html/${format}/${ancestorLevel}`; + + const makeFileURL = (path: string) => new URL(path, testRealmURL).href; + + const gifDefCodeRef = (): ResolvedCodeRef => ({ + module: `${baseRealm.url}gif-image-def`, + name: 'GifDef', + }); + + async function captureFileExtractResult( + expectedStatus?: 'ready' | 'error', + ): Promise { + await waitUntil( + () => { + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + return false; + } + let status = container.getAttribute( + 'data-prerender-file-extract-status', + ); + if (!status) { + return false; + } + if (expectedStatus && status !== expectedStatus) { + return false; + } + return status === 'ready' || status === 'error'; + }, + { timeout: 5000 }, + ); + + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + throw new Error( + 'captureFileExtractResult: missing [data-prerender-file-extract] container after wait', + ); + } + let pre = container.querySelector('pre'); + let text = pre?.textContent?.trim() ?? ''; + return JSON.parse(text) as FileExtractResponse; + } + + hooks.beforeEach(async function () { + let gifBytes = makeMinimalGif(6, 7); + ({ realm } = await setupAcceptanceTestRealm({ + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + 'sample.gif': gifBytes, + 'not-a-gif.gif': 'This is plain text, not a GIF file.', + }, + })); + }); + + hooks.afterEach(function () { + delete (globalThis as any).__renderModel; + delete (globalThis as any).__boxelFileRenderData; + }); + + test('extracts width and height from GIF', async function (assert) { + let url = makeFileURL('sample.gif'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: gifDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.strictEqual(result.searchDoc?.width, 6, 'extracts GIF width'); + assert.strictEqual(result.searchDoc?.height, 7, 'extracts GIF height'); + assert.strictEqual(result.searchDoc?.name, 'sample.gif'); + assert.ok( + String(result.searchDoc?.contentType).includes('gif'), + 'sets gif content type', + ); + }); + + test('falls back when GifDef is used for non-GIF content', async function (assert) { + let url = makeFileURL('not-a-gif.gif'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: gifDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.true( + result.mismatch, + 'marks mismatch when content is not valid GIF', + ); + assert.strictEqual(result.searchDoc?.name, 'not-a-gif.gif'); + }); + + test('isolated template renders img with width and height attributes', async function (assert) { + let url = makeFileURL('sample.gif'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: gifDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: gifDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: gifDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.strictEqual( + img?.getAttribute('width'), + '6', + 'img has correct width attribute', + ); + assert.strictEqual( + img?.getAttribute('height'), + '7', + 'img has correct height attribute', + ); + assert.ok( + img?.getAttribute('src')?.includes('sample.gif'), + 'img src references the GIF file', + ); + }); + + test('indexing stores GIF metadata and file meta uses it', async function (assert) { + let fileURL = new URL('sample.gif', testRealmURL); + let fileEntry = await realm.realmIndexQueryEngine.file(fileURL); + + assert.ok(fileEntry, 'file entry exists'); + assert.strictEqual( + fileEntry?.searchDoc?.width, + 6, + 'index stores GIF width', + ); + assert.strictEqual( + fileEntry?.searchDoc?.height, + 7, + 'index stores GIF height', + ); + + let network = getService('network') as NetworkService; + let response = await network.virtualNetwork.fetch(fileURL, { + headers: { Accept: SupportedMimeType.FileMeta }, + }); + + assert.true(response.ok, 'file meta request succeeds'); + + let body = await response.json(); + assert.strictEqual(body?.data?.type, 'file-meta'); + assert.ok( + String(body?.data?.attributes?.contentType).includes('gif'), + 'file meta uses gif content type', + ); + assert.strictEqual( + body?.data?.attributes?.width, + 6, + 'file meta includes GIF width', + ); + assert.strictEqual( + body?.data?.attributes?.height, + 7, + 'file meta includes GIF height', + ); + assert.deepEqual( + body?.data?.meta?.adoptsFrom, + gifDefCodeRef(), + 'file meta uses GIF def', + ); + }); + + test('authenticated images display in browser', async function (assert) { + let url = makeFileURL('sample.gif'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: gifDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: gifDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: gifDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] .image-isolated__img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.ok( + img?.getAttribute('src')?.includes('sample.gif'), + 'img src references the GIF file', + ); + + // Wait for the image to actually load and verify it has non-zero dimensions. + await waitUntil(() => img!.naturalWidth > 0, { + timeout: 5000, + timeoutMessage: + 'Image failed to load - naturalWidth remained 0. This likely indicates an authentication issue preventing the browser from fetching the image.', + }); + + assert.ok( + img!.naturalWidth > 0, + 'Image loaded successfully with non-zero width', + ); + assert.ok( + img!.naturalHeight > 0, + 'Image loaded successfully with non-zero height', + ); + }); +}); diff --git a/packages/host/tests/acceptance/image-def/jpg-image-def-test.gts b/packages/host/tests/acceptance/image-def/jpg-image-def-test.gts new file mode 100644 index 00000000000..793f3e3a172 --- /dev/null +++ b/packages/host/tests/acceptance/image-def/jpg-image-def-test.gts @@ -0,0 +1,334 @@ +import { visit, waitUntil } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; + +import { module, test } from 'qunit'; + +import { + baseRealm, + type FileExtractResponse, + type RenderRouteOptions, + type ResolvedCodeRef, + SupportedMimeType, +} from '@cardstack/runtime-common'; +import type { Realm } from '@cardstack/runtime-common/realm'; + +import type NetworkService from '@cardstack/host/services/network'; + +import { + setupLocalIndexing, + setupOnSave, + testRealmURL, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + capturePrerenderResult, +} from '../../helpers'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupApplicationTest } from '../../helpers/setup'; +import { setupTestRealmServiceWorker } from '../../helpers/test-realm-service-worker'; + +// A valid 4x5 JPEG generated by ImageMagick. Unlike a header-only stub this +// includes quantisation tables, Huffman tables and scan data so browsers can +// actually decode and display the image. +// prettier-ignore +const VALID_JPEG_4x5 = new Uint8Array([ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, + 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, + 0x02, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x04, 0x06, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08, 0x06, + 0x06, 0x05, 0x06, 0x09, 0x08, 0x0a, 0x0a, 0x09, 0x08, 0x09, 0x09, 0x0a, 0x0c, 0x0f, 0x0c, 0x0a, + 0x0b, 0x0e, 0x0b, 0x09, 0x09, 0x0d, 0x11, 0x0d, 0x0e, 0x0f, 0x10, 0x10, 0x11, 0x10, 0x0a, 0x0c, + 0x12, 0x13, 0x12, 0x10, 0x13, 0x0f, 0x10, 0x10, 0x10, 0xff, 0xdb, 0x00, 0x43, 0x01, 0x03, 0x03, + 0x03, 0x04, 0x03, 0x04, 0x08, 0x04, 0x04, 0x08, 0x10, 0x0b, 0x09, 0x0b, 0x10, 0x10, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0xff, 0xc0, + 0x00, 0x11, 0x08, 0x00, 0x05, 0x00, 0x04, 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, + 0x01, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0xff, 0xc4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xc4, 0x00, + 0x15, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x07, 0x09, 0xff, 0xc4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, + 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00, 0x3a, 0x03, 0x15, 0x4d, 0xff, 0xd9, +]); + +module('Acceptance | jpg image def', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + setupTestRealmServiceWorker(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + }); + let realm: Realm; + + const fileExtractPath = ( + url: string, + renderOptions: RenderRouteOptions, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/file-extract`; + + const fileRenderPath = ( + url: string, + renderOptions: RenderRouteOptions, + format = 'isolated', + ancestorLevel = 0, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/html/${format}/${ancestorLevel}`; + + const makeFileURL = (path: string) => new URL(path, testRealmURL).href; + + const jpgDefCodeRef = (): ResolvedCodeRef => ({ + module: `${baseRealm.url}jpg-image-def`, + name: 'JpgDef', + }); + + async function captureFileExtractResult( + expectedStatus?: 'ready' | 'error', + ): Promise { + await waitUntil( + () => { + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + return false; + } + let status = container.getAttribute( + 'data-prerender-file-extract-status', + ); + if (!status) { + return false; + } + if (expectedStatus && status !== expectedStatus) { + return false; + } + return status === 'ready' || status === 'error'; + }, + { timeout: 5000 }, + ); + + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + throw new Error( + 'captureFileExtractResult: missing [data-prerender-file-extract] container after wait', + ); + } + let pre = container.querySelector('pre'); + let text = pre?.textContent?.trim() ?? ''; + return JSON.parse(text) as FileExtractResponse; + } + + hooks.beforeEach(async function () { + ({ realm } = await setupAcceptanceTestRealm({ + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + 'sample.jpg': VALID_JPEG_4x5, + 'not-a-jpg.jpg': 'This is plain text, not a JPEG file.', + }, + })); + }); + + hooks.afterEach(function () { + delete (globalThis as any).__renderModel; + delete (globalThis as any).__boxelFileRenderData; + }); + + test('extracts width and height from JPEG', async function (assert) { + let url = makeFileURL('sample.jpg'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: jpgDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.strictEqual(result.searchDoc?.width, 4, 'extracts JPEG width'); + assert.strictEqual(result.searchDoc?.height, 5, 'extracts JPEG height'); + assert.strictEqual(result.searchDoc?.name, 'sample.jpg'); + assert.ok( + String(result.searchDoc?.contentType).includes('jpeg'), + 'sets jpeg content type', + ); + }); + + test('falls back when JpgDef is used for non-JPEG content', async function (assert) { + let url = makeFileURL('not-a-jpg.jpg'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: jpgDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.true( + result.mismatch, + 'marks mismatch when content is not valid JPEG', + ); + assert.strictEqual(result.searchDoc?.name, 'not-a-jpg.jpg'); + }); + + test('isolated template renders img with width and height attributes', async function (assert) { + let url = makeFileURL('sample.jpg'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: jpgDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: jpgDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: jpgDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.strictEqual( + img?.getAttribute('width'), + '4', + 'img has correct width attribute', + ); + assert.strictEqual( + img?.getAttribute('height'), + '5', + 'img has correct height attribute', + ); + assert.ok( + img?.getAttribute('src')?.includes('sample.jpg'), + 'img src references the JPEG file', + ); + }); + + test('indexing stores JPEG metadata and file meta uses it', async function (assert) { + let fileURL = new URL('sample.jpg', testRealmURL); + let fileEntry = await realm.realmIndexQueryEngine.file(fileURL); + + assert.ok(fileEntry, 'file entry exists'); + assert.strictEqual( + fileEntry?.searchDoc?.width, + 4, + 'index stores JPEG width', + ); + assert.strictEqual( + fileEntry?.searchDoc?.height, + 5, + 'index stores JPEG height', + ); + + let network = getService('network') as NetworkService; + let response = await network.virtualNetwork.fetch(fileURL, { + headers: { Accept: SupportedMimeType.FileMeta }, + }); + + assert.true(response.ok, 'file meta request succeeds'); + + let body = await response.json(); + assert.strictEqual(body?.data?.type, 'file-meta'); + assert.ok( + String(body?.data?.attributes?.contentType).includes('jpeg'), + 'file meta uses jpeg content type', + ); + assert.strictEqual( + body?.data?.attributes?.width, + 4, + 'file meta includes JPEG width', + ); + assert.strictEqual( + body?.data?.attributes?.height, + 5, + 'file meta includes JPEG height', + ); + assert.deepEqual( + body?.data?.meta?.adoptsFrom, + jpgDefCodeRef(), + 'file meta uses JPEG def', + ); + }); + + test('authenticated images display in browser', async function (assert) { + let url = makeFileURL('sample.jpg'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: jpgDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: jpgDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: jpgDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] .image-isolated__img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.ok( + img?.getAttribute('src')?.includes('sample.jpg'), + 'img src references the JPEG file', + ); + + // Wait for the image to actually load and verify it has non-zero dimensions. + await waitUntil(() => img!.naturalWidth > 0, { + timeout: 5000, + timeoutMessage: + 'Image failed to load - naturalWidth remained 0. This likely indicates an authentication issue preventing the browser from fetching the image.', + }); + + assert.ok( + img!.naturalWidth > 0, + 'Image loaded successfully with non-zero width', + ); + assert.ok( + img!.naturalHeight > 0, + 'Image loaded successfully with non-zero height', + ); + }); +}); diff --git a/packages/host/tests/acceptance/image-def/png-image-def-test.gts b/packages/host/tests/acceptance/image-def/png-image-def-test.gts new file mode 100644 index 00000000000..fb09dcb67af --- /dev/null +++ b/packages/host/tests/acceptance/image-def/png-image-def-test.gts @@ -0,0 +1,413 @@ +import { visit, waitUntil } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; + +import { module, test } from 'qunit'; + +import { + baseRealm, + type FileExtractResponse, + type RenderRouteOptions, + type ResolvedCodeRef, + SupportedMimeType, +} from '@cardstack/runtime-common'; +import type { Realm } from '@cardstack/runtime-common/realm'; + +import type NetworkService from '@cardstack/host/services/network'; + +import { + setupLocalIndexing, + setupOnSave, + testRealmURL, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + capturePrerenderResult, +} from '../../helpers'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupApplicationTest } from '../../helpers/setup'; +import { setupTestRealmServiceWorker } from '../../helpers/test-realm-service-worker'; + +// Minimal valid PNG with correct pixel data. +// Dimensions encoded in IHDR at bytes 16-19 (width) and 20-23 (height). +function makeMinimalPng(width: number, height: number): Uint8Array { + // PNG signature (8 bytes) + let signature = [137, 80, 78, 71, 13, 10, 26, 10]; + + // IHDR chunk: width, height, bit depth=8, color type=2 (RGB) + let ihdrData = new Uint8Array(13); + let ihdrView = new DataView(ihdrData.buffer); + ihdrView.setUint32(0, width); + ihdrView.setUint32(4, height); + ihdrData[8] = 8; // bit depth + ihdrData[9] = 2; // color type (RGB) + ihdrData[10] = 0; // compression + ihdrData[11] = 0; // filter + ihdrData[12] = 0; // interlace + + let ihdrChunk = buildChunk('IHDR', ihdrData); + + // IDAT chunk: zlib-compressed scanlines (filter=None, all black pixels) + let scanlineBytes = 1 + width * 3; // filter byte + RGB per pixel + let rawSize = height * scanlineBytes; + let rawData = new Uint8Array(rawSize); // all zeros = filter None + black + + // zlib wrapper: CMF + FLG + stored deflate block + Adler-32 + let zlibSize = 2 + 5 + rawSize + 4; + let zlib = new Uint8Array(zlibSize); + let zi = 0; + zlib[zi++] = 0x08; // CMF: deflate, window 256 + zlib[zi++] = 0x1d; // FLG: FCHECK so (CMF*256+FLG) % 31 == 0 + zlib[zi++] = 0x01; // BFINAL=1, BTYPE=00 (stored) + zlib[zi++] = rawSize & 0xff; + zlib[zi++] = (rawSize >> 8) & 0xff; + zlib[zi++] = ~rawSize & 0xff; + zlib[zi++] = (~rawSize >> 8) & 0xff; + zlib.set(rawData, zi); + zi += rawSize; + // Adler-32 of all-zero data: s1=1, s2=rawSize + let adler = ((rawSize & 0xffff) << 16) | 1; + zlib[zi++] = (adler >> 24) & 0xff; + zlib[zi++] = (adler >> 16) & 0xff; + zlib[zi++] = (adler >> 8) & 0xff; + zlib[zi++] = adler & 0xff; + + let idatChunk = buildChunk('IDAT', zlib); + + // IEND chunk (empty) + let iendChunk = buildChunk('IEND', new Uint8Array(0)); + + // Combine all parts + let totalLength = + signature.length + ihdrChunk.length + idatChunk.length + iendChunk.length; + let png = new Uint8Array(totalLength); + let offset = 0; + + png.set(signature, offset); + offset += signature.length; + png.set(ihdrChunk, offset); + offset += ihdrChunk.length; + png.set(idatChunk, offset); + offset += idatChunk.length; + png.set(iendChunk, offset); + + return png; +} + +function buildChunk(type: string, data: Uint8Array): Uint8Array { + // chunk = length (4 bytes) + type (4 bytes) + data + CRC (4 bytes) + let chunk = new Uint8Array(4 + 4 + data.length + 4); + let view = new DataView(chunk.buffer); + + // Length + view.setUint32(0, data.length); + + // Type + for (let i = 0; i < 4; i++) { + chunk[4 + i] = type.charCodeAt(i); + } + + // Data + chunk.set(data, 8); + + // CRC (simplified — not validated by our extractor, but needed for structure) + let crc = crc32(chunk.slice(4, 8 + data.length)); + view.setUint32(8 + data.length, crc); + + return chunk; +} + +function crc32(data: Uint8Array): number { + let crc = 0xffffffff; + for (let i = 0; i < data.length; i++) { + crc ^= data[i]!; + for (let j = 0; j < 8; j++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +module('Acceptance | png image def', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + setupTestRealmServiceWorker(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + }); + let realm: Realm; + + const fileExtractPath = ( + url: string, + renderOptions: RenderRouteOptions, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/file-extract`; + + const fileRenderPath = ( + url: string, + renderOptions: RenderRouteOptions, + format = 'isolated', + ancestorLevel = 0, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/html/${format}/${ancestorLevel}`; + + const makeFileURL = (path: string) => new URL(path, testRealmURL).href; + + const pngDefCodeRef = (): ResolvedCodeRef => ({ + module: `${baseRealm.url}png-image-def`, + name: 'PngDef', + }); + + async function captureFileExtractResult( + expectedStatus?: 'ready' | 'error', + ): Promise { + await waitUntil( + () => { + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + return false; + } + let status = container.getAttribute( + 'data-prerender-file-extract-status', + ); + if (!status) { + return false; + } + if (expectedStatus && status !== expectedStatus) { + return false; + } + return status === 'ready' || status === 'error'; + }, + { timeout: 5000 }, + ); + + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + throw new Error( + 'captureFileExtractResult: missing [data-prerender-file-extract] container after wait', + ); + } + let pre = container.querySelector('pre'); + let text = pre?.textContent?.trim() ?? ''; + return JSON.parse(text) as FileExtractResponse; + } + + hooks.beforeEach(async function () { + let pngBytes = makeMinimalPng(2, 3); + ({ realm } = await setupAcceptanceTestRealm({ + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + 'sample.png': pngBytes, + 'not-a-png.png': 'This is plain text, not a PNG file.', + }, + })); + }); + + hooks.afterEach(function () { + delete (globalThis as any).__renderModel; + delete (globalThis as any).__boxelFileRenderData; + }); + + test('extracts width and height from PNG', async function (assert) { + let url = makeFileURL('sample.png'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: pngDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.strictEqual(result.searchDoc?.width, 2, 'extracts PNG width'); + assert.strictEqual(result.searchDoc?.height, 3, 'extracts PNG height'); + assert.strictEqual(result.searchDoc?.name, 'sample.png'); + assert.ok( + String(result.searchDoc?.contentType).includes('png'), + 'sets png content type', + ); + }); + + test('falls back when PngDef is used for non-PNG content', async function (assert) { + let url = makeFileURL('not-a-png.png'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: pngDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.true( + result.mismatch, + 'marks mismatch when content is not valid PNG', + ); + assert.strictEqual(result.searchDoc?.name, 'not-a-png.png'); + }); + + test('isolated template renders img with width and height attributes', async function (assert) { + let url = makeFileURL('sample.png'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: pngDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: pngDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: pngDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.strictEqual( + img?.getAttribute('width'), + '2', + 'img has correct width attribute', + ); + assert.strictEqual( + img?.getAttribute('height'), + '3', + 'img has correct height attribute', + ); + assert.ok( + img?.getAttribute('src')?.includes('sample.png'), + 'img src references the PNG file', + ); + }); + + test('indexing stores PNG metadata and file meta uses it', async function (assert) { + let fileURL = new URL('sample.png', testRealmURL); + let fileEntry = await realm.realmIndexQueryEngine.file(fileURL); + + assert.ok(fileEntry, 'file entry exists'); + assert.strictEqual( + fileEntry?.searchDoc?.width, + 2, + 'index stores PNG width', + ); + assert.strictEqual( + fileEntry?.searchDoc?.height, + 3, + 'index stores PNG height', + ); + + let network = getService('network') as NetworkService; + let response = await network.virtualNetwork.fetch(fileURL, { + headers: { Accept: SupportedMimeType.FileMeta }, + }); + + assert.true(response.ok, 'file meta request succeeds'); + + let body = await response.json(); + assert.strictEqual(body?.data?.type, 'file-meta'); + assert.ok( + String(body?.data?.attributes?.contentType).includes('png'), + 'file meta uses png content type', + ); + assert.strictEqual( + body?.data?.attributes?.width, + 2, + 'file meta includes PNG width', + ); + assert.strictEqual( + body?.data?.attributes?.height, + 3, + 'file meta includes PNG height', + ); + assert.deepEqual( + body?.data?.meta?.adoptsFrom, + pngDefCodeRef(), + 'file meta uses PNG def', + ); + }); + + test('authenticated images display in browser', async function (assert) { + let url = makeFileURL('sample.png'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: pngDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: pngDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: pngDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] .image-isolated__img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.ok( + img?.getAttribute('src')?.includes('sample.png'), + 'img src references the PNG file', + ); + + // Wait for the image to actually load and verify it has non-zero dimensions. + // This assertion will fail if the browser cannot fetch the image (e.g., 401 errors + // due to missing authentication cookies). Once cookie-based auth is implemented, + // this test should pass. + await waitUntil(() => img!.naturalWidth > 0, { + timeout: 5000, + timeoutMessage: + 'Image failed to load - naturalWidth remained 0. This likely indicates an authentication issue preventing the browser from fetching the image.', + }); + + assert.ok( + img!.naturalWidth > 0, + 'Image loaded successfully with non-zero width', + ); + assert.ok( + img!.naturalHeight > 0, + 'Image loaded successfully with non-zero height', + ); + }); +}); diff --git a/packages/host/tests/acceptance/image-def/svg-image-def-test.gts b/packages/host/tests/acceptance/image-def/svg-image-def-test.gts new file mode 100644 index 00000000000..e79db4e3023 --- /dev/null +++ b/packages/host/tests/acceptance/image-def/svg-image-def-test.gts @@ -0,0 +1,338 @@ +import { visit, waitUntil } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; + +import { module, test } from 'qunit'; + +import { + baseRealm, + type FileExtractResponse, + type RenderRouteOptions, + type ResolvedCodeRef, + SupportedMimeType, +} from '@cardstack/runtime-common'; +import type { Realm } from '@cardstack/runtime-common/realm'; + +import type NetworkService from '@cardstack/host/services/network'; + +import { + setupLocalIndexing, + setupOnSave, + testRealmURL, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + capturePrerenderResult, +} from '../../helpers'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupApplicationTest } from '../../helpers/setup'; +import { setupTestRealmServiceWorker } from '../../helpers/test-realm-service-worker'; + +function makeMinimalSvg(width: number, height: number): string { + return ``; +} + +module('Acceptance | svg image def', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + setupTestRealmServiceWorker(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + }); + let realm: Realm; + + const fileExtractPath = ( + url: string, + renderOptions: RenderRouteOptions, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/file-extract`; + + const fileRenderPath = ( + url: string, + renderOptions: RenderRouteOptions, + format = 'isolated', + ancestorLevel = 0, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/html/${format}/${ancestorLevel}`; + + const makeFileURL = (path: string) => new URL(path, testRealmURL).href; + + const svgDefCodeRef = (): ResolvedCodeRef => ({ + module: `${baseRealm.url}svg-image-def`, + name: 'SvgDef', + }); + + async function captureFileExtractResult( + expectedStatus?: 'ready' | 'error', + ): Promise { + await waitUntil( + () => { + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + return false; + } + let status = container.getAttribute( + 'data-prerender-file-extract-status', + ); + if (!status) { + return false; + } + if (expectedStatus && status !== expectedStatus) { + return false; + } + return status === 'ready' || status === 'error'; + }, + { timeout: 5000 }, + ); + + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + throw new Error( + 'captureFileExtractResult: missing [data-prerender-file-extract] container after wait', + ); + } + let pre = container.querySelector('pre'); + let text = pre?.textContent?.trim() ?? ''; + return JSON.parse(text) as FileExtractResponse; + } + + hooks.beforeEach(async function () { + ({ realm } = await setupAcceptanceTestRealm({ + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + 'sample.svg': makeMinimalSvg(120, 80), + 'viewbox-only.svg': + '', + 'not-an-svg.svg': 'This is plain text, not an SVG file.', + }, + })); + }); + + hooks.afterEach(function () { + delete (globalThis as any).__renderModel; + delete (globalThis as any).__boxelFileRenderData; + }); + + test('extracts width and height from SVG with explicit attributes', async function (assert) { + let url = makeFileURL('sample.svg'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: svgDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.strictEqual(result.searchDoc?.width, 120, 'extracts SVG width'); + assert.strictEqual(result.searchDoc?.height, 80, 'extracts SVG height'); + assert.strictEqual(result.searchDoc?.name, 'sample.svg'); + assert.ok( + String(result.searchDoc?.contentType).includes('svg'), + 'sets svg content type', + ); + }); + + test('extracts dimensions from viewBox when width/height attributes are absent', async function (assert) { + let url = makeFileURL('viewbox-only.svg'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: svgDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.strictEqual( + result.searchDoc?.width, + 200, + 'extracts width from viewBox', + ); + assert.strictEqual( + result.searchDoc?.height, + 150, + 'extracts height from viewBox', + ); + }); + + test('falls back when SvgDef is used for non-SVG content', async function (assert) { + let url = makeFileURL('not-an-svg.svg'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: svgDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.true( + result.mismatch, + 'marks mismatch when content is not valid SVG', + ); + assert.strictEqual(result.searchDoc?.name, 'not-an-svg.svg'); + }); + + test('isolated template renders img with width and height attributes', async function (assert) { + let url = makeFileURL('sample.svg'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: svgDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: svgDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: svgDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.strictEqual( + img?.getAttribute('width'), + '120', + 'img has correct width attribute', + ); + assert.strictEqual( + img?.getAttribute('height'), + '80', + 'img has correct height attribute', + ); + assert.ok( + img?.getAttribute('src')?.includes('sample.svg'), + 'img src references the SVG file', + ); + }); + + test('indexing stores SVG metadata and file meta uses it', async function (assert) { + let fileURL = new URL('sample.svg', testRealmURL); + let fileEntry = await realm.realmIndexQueryEngine.file(fileURL); + + assert.ok(fileEntry, 'file entry exists'); + assert.strictEqual( + fileEntry?.searchDoc?.width, + 120, + 'index stores SVG width', + ); + assert.strictEqual( + fileEntry?.searchDoc?.height, + 80, + 'index stores SVG height', + ); + + let network = getService('network') as NetworkService; + let response = await network.virtualNetwork.fetch(fileURL, { + headers: { Accept: SupportedMimeType.FileMeta }, + }); + + assert.true(response.ok, 'file meta request succeeds'); + + let body = await response.json(); + assert.strictEqual(body?.data?.type, 'file-meta'); + assert.ok( + String(body?.data?.attributes?.contentType).includes('svg'), + 'file meta uses svg content type', + ); + assert.strictEqual( + body?.data?.attributes?.width, + 120, + 'file meta includes SVG width', + ); + assert.strictEqual( + body?.data?.attributes?.height, + 80, + 'file meta includes SVG height', + ); + assert.deepEqual( + body?.data?.meta?.adoptsFrom, + svgDefCodeRef(), + 'file meta uses SVG def', + ); + }); + + test('authenticated images display in browser', async function (assert) { + let url = makeFileURL('sample.svg'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: svgDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: svgDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: svgDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] .image-isolated__img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.ok( + img?.getAttribute('src')?.includes('sample.svg'), + 'img src references the SVG file', + ); + + // Wait for the image to actually load and verify it has non-zero dimensions. + await waitUntil(() => img!.naturalWidth > 0, { + timeout: 5000, + timeoutMessage: + 'Image failed to load - naturalWidth remained 0. This likely indicates an authentication issue preventing the browser from fetching the image.', + }); + + assert.ok( + img!.naturalWidth > 0, + 'Image loaded successfully with non-zero width', + ); + assert.ok( + img!.naturalHeight > 0, + 'Image loaded successfully with non-zero height', + ); + }); +}); diff --git a/packages/host/tests/acceptance/image-def/webp-image-def-test.gts b/packages/host/tests/acceptance/image-def/webp-image-def-test.gts new file mode 100644 index 00000000000..a6f33e81f0f --- /dev/null +++ b/packages/host/tests/acceptance/image-def/webp-image-def-test.gts @@ -0,0 +1,320 @@ +import { visit, waitUntil } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; + +import { module, test } from 'qunit'; + +import { + baseRealm, + type FileExtractResponse, + type RenderRouteOptions, + type ResolvedCodeRef, + SupportedMimeType, +} from '@cardstack/runtime-common'; +import type { Realm } from '@cardstack/runtime-common/realm'; + +import type NetworkService from '@cardstack/host/services/network'; + +import { + setupLocalIndexing, + setupOnSave, + testRealmURL, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + capturePrerenderResult, +} from '../../helpers'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupApplicationTest } from '../../helpers/setup'; +import { setupTestRealmServiceWorker } from '../../helpers/test-realm-service-worker'; + +// A valid 8x9 WebP (lossy VP8) generated by ImageMagick. Unlike a header-only +// stub this includes actual VP8 frame data so browsers can decode and display it. +// prettier-ignore +const VALID_WEBP_8x9 = new Uint8Array([ + 0x52, 0x49, 0x46, 0x46, 0x3c, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20, + 0x30, 0x00, 0x00, 0x00, 0xd0, 0x01, 0x00, 0x9d, 0x01, 0x2a, 0x08, 0x00, 0x09, 0x00, 0x02, 0x00, + 0x34, 0x25, 0xa0, 0x02, 0x74, 0xba, 0x01, 0xf8, 0x00, 0x03, 0xb0, 0x00, 0xfe, 0xf0, 0xc4, 0x0b, + 0xff, 0x20, 0xb9, 0x61, 0x75, 0xc8, 0xd7, 0xff, 0x20, 0x3f, 0xe4, 0x07, 0xfc, 0x80, 0xff, 0xf8, + 0xf2, 0x00, 0x00, 0x00, +]); + +module('Acceptance | webp image def', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + setupTestRealmServiceWorker(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + }); + let realm: Realm; + + const fileExtractPath = ( + url: string, + renderOptions: RenderRouteOptions, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/file-extract`; + + const fileRenderPath = ( + url: string, + renderOptions: RenderRouteOptions, + format = 'isolated', + ancestorLevel = 0, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/html/${format}/${ancestorLevel}`; + + const makeFileURL = (path: string) => new URL(path, testRealmURL).href; + + const webpDefCodeRef = (): ResolvedCodeRef => ({ + module: `${baseRealm.url}webp-image-def`, + name: 'WebpDef', + }); + + async function captureFileExtractResult( + expectedStatus?: 'ready' | 'error', + ): Promise { + await waitUntil( + () => { + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + return false; + } + let status = container.getAttribute( + 'data-prerender-file-extract-status', + ); + if (!status) { + return false; + } + if (expectedStatus && status !== expectedStatus) { + return false; + } + return status === 'ready' || status === 'error'; + }, + { timeout: 5000 }, + ); + + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + throw new Error( + 'captureFileExtractResult: missing [data-prerender-file-extract] container after wait', + ); + } + let pre = container.querySelector('pre'); + let text = pre?.textContent?.trim() ?? ''; + return JSON.parse(text) as FileExtractResponse; + } + + hooks.beforeEach(async function () { + ({ realm } = await setupAcceptanceTestRealm({ + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + 'sample.webp': VALID_WEBP_8x9, + 'not-a-webp.webp': 'This is plain text, not a WebP file.', + }, + })); + }); + + hooks.afterEach(function () { + delete (globalThis as any).__renderModel; + delete (globalThis as any).__boxelFileRenderData; + }); + + test('extracts width and height from WebP', async function (assert) { + let url = makeFileURL('sample.webp'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: webpDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.strictEqual(result.searchDoc?.width, 8, 'extracts WebP width'); + assert.strictEqual(result.searchDoc?.height, 9, 'extracts WebP height'); + assert.strictEqual(result.searchDoc?.name, 'sample.webp'); + assert.ok( + String(result.searchDoc?.contentType).includes('webp'), + 'sets webp content type', + ); + }); + + test('falls back when WebpDef is used for non-WebP content', async function (assert) { + let url = makeFileURL('not-a-webp.webp'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: webpDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.true( + result.mismatch, + 'marks mismatch when content is not valid WebP', + ); + assert.strictEqual(result.searchDoc?.name, 'not-a-webp.webp'); + }); + + test('isolated template renders img with width and height attributes', async function (assert) { + let url = makeFileURL('sample.webp'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: webpDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: webpDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: webpDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.strictEqual( + img?.getAttribute('width'), + '8', + 'img has correct width attribute', + ); + assert.strictEqual( + img?.getAttribute('height'), + '9', + 'img has correct height attribute', + ); + assert.ok( + img?.getAttribute('src')?.includes('sample.webp'), + 'img src references the WebP file', + ); + }); + + test('indexing stores WebP metadata and file meta uses it', async function (assert) { + let fileURL = new URL('sample.webp', testRealmURL); + let fileEntry = await realm.realmIndexQueryEngine.file(fileURL); + + assert.ok(fileEntry, 'file entry exists'); + assert.strictEqual( + fileEntry?.searchDoc?.width, + 8, + 'index stores WebP width', + ); + assert.strictEqual( + fileEntry?.searchDoc?.height, + 9, + 'index stores WebP height', + ); + + let network = getService('network') as NetworkService; + let response = await network.virtualNetwork.fetch(fileURL, { + headers: { Accept: SupportedMimeType.FileMeta }, + }); + + assert.true(response.ok, 'file meta request succeeds'); + + let body = await response.json(); + assert.strictEqual(body?.data?.type, 'file-meta'); + assert.ok( + String(body?.data?.attributes?.contentType).includes('webp'), + 'file meta uses webp content type', + ); + assert.strictEqual( + body?.data?.attributes?.width, + 8, + 'file meta includes WebP width', + ); + assert.strictEqual( + body?.data?.attributes?.height, + 9, + 'file meta includes WebP height', + ); + assert.deepEqual( + body?.data?.meta?.adoptsFrom, + webpDefCodeRef(), + 'file meta uses WebP def', + ); + }); + + test('authenticated images display in browser', async function (assert) { + let url = makeFileURL('sample.webp'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: webpDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: webpDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: webpDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] .image-isolated__img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.ok( + img?.getAttribute('src')?.includes('sample.webp'), + 'img src references the WebP file', + ); + + // Wait for the image to actually load and verify it has non-zero dimensions. + await waitUntil(() => img!.naturalWidth > 0, { + timeout: 5000, + timeoutMessage: + 'Image failed to load - naturalWidth remained 0. This likely indicates an authentication issue preventing the browser from fetching the image.', + }); + + assert.ok( + img!.naturalWidth > 0, + 'Image loaded successfully with non-zero width', + ); + assert.ok( + img!.naturalHeight > 0, + 'Image loaded successfully with non-zero height', + ); + }); +}); diff --git a/packages/host/tests/helpers/adapter.ts b/packages/host/tests/helpers/adapter.ts index 8d3fa14b578..303738359d2 100644 --- a/packages/host/tests/helpers/adapter.ts +++ b/packages/host/tests/helpers/adapter.ts @@ -57,7 +57,7 @@ class TokenExpiredError extends Error {} class JsonWebTokenError extends Error {} interface TestAdapterContents { - [path: string]: string | object; + [path: string]: string | object | Uint8Array; } let shimmedModuleIndicator = '// this file is shimmed'; @@ -255,7 +255,9 @@ export class TestRealmAdapter implements RealmAdapter { let fileRefContent: string | Uint8Array = ''; - if (path.endsWith('.json')) { + if (value instanceof Uint8Array) { + fileRefContent = value; + } else if (path.endsWith('.json')) { let cardApi = await this.#loader.import( `${baseRealm.url}card-api`, ); @@ -272,8 +274,6 @@ export class TestRealmAdapter implements RealmAdapter { } else { fileRefContent = shimmedModuleIndicator; } - } else if (value instanceof Uint8Array) { - fileRefContent = value; } else { fileRefContent = value as string; } diff --git a/packages/host/tests/helpers/index.gts b/packages/host/tests/helpers/index.gts index a5a56aec045..7555bfc21bd 100644 --- a/packages/host/tests/helpers/index.gts +++ b/packages/host/tests/helpers/index.gts @@ -500,7 +500,8 @@ interface RealmContents { | LooseSingleCardDocument | RealmInfo | Record - | string; + | string + | Uint8Array; } export const SYSTEM_CARD_FIXTURE_CONTENTS: RealmContents = { @@ -997,6 +998,62 @@ export function delay(delayAmountMs: number): Promise { }); } +// Create minimal valid PNG bytes for testing (1x1 pixel by default) +export function makeMinimalPng(width = 1, height = 1): Uint8Array { + let signature = [137, 80, 78, 71, 13, 10, 26, 10]; + let ihdrData = new Uint8Array(13); + let ihdrView = new DataView(ihdrData.buffer); + ihdrView.setUint32(0, width); + ihdrView.setUint32(4, height); + ihdrData[8] = 8; // bit depth + ihdrData[9] = 2; // color type (RGB) + ihdrData[10] = 0; // compression + ihdrData[11] = 0; // filter + ihdrData[12] = 0; // interlace + let ihdrChunk = buildPngChunk('IHDR', ihdrData); + let idatData = new Uint8Array([ + 0x08, 0xd7, 0x01, 0x00, 0x00, 0xff, 0xff, 0x00, 0x01, 0x00, 0x01, + ]); + let idatChunk = buildPngChunk('IDAT', idatData); + let iendChunk = buildPngChunk('IEND', new Uint8Array(0)); + let totalLength = + signature.length + ihdrChunk.length + idatChunk.length + iendChunk.length; + let png = new Uint8Array(totalLength); + let offset = 0; + png.set(signature, offset); + offset += signature.length; + png.set(ihdrChunk, offset); + offset += ihdrChunk.length; + png.set(idatChunk, offset); + offset += idatChunk.length; + png.set(iendChunk, offset); + return png; +} + +function buildPngChunk(type: string, data: Uint8Array): Uint8Array { + let chunk = new Uint8Array(4 + 4 + data.length + 4); + let view = new DataView(chunk.buffer); + view.setUint32(0, data.length); + for (let i = 0; i < 4; i++) { + chunk[4 + i] = type.charCodeAt(i); + } + chunk.set(data, 8); + let crc = crc32Png(chunk.slice(4, 8 + data.length)); + view.setUint32(8 + data.length, crc); + return chunk; +} + +function crc32Png(data: Uint8Array): number { + let crc = 0xffffffff; + for (let i = 0; i < data.length; i++) { + crc ^= data[i]!; + for (let j = 0; j < 8; j++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + } + return (crc ^ 0xffffffff) >>> 0; +} + // --- Created-at test utilities --- // Returns created_at (epoch seconds) from realm_file_meta for a given local file path like 'Pet/mango.json'. export async function getFileCreatedAt( diff --git a/packages/host/tests/helpers/test-realm-service-worker.ts b/packages/host/tests/helpers/test-realm-service-worker.ts new file mode 100644 index 00000000000..d44710e0291 --- /dev/null +++ b/packages/host/tests/helpers/test-realm-service-worker.ts @@ -0,0 +1,79 @@ +import { getService } from '@universal-ember/test-support'; + +import type NetworkService from '@cardstack/host/services/network'; + +let swReady: Promise | undefined; + +async function ensureRegistered(): Promise { + if (swReady) { + return swReady; + } + swReady = (async () => { + let reg = await navigator.serviceWorker.register('/test-realm-sw.js'); + let sw = reg.installing || reg.waiting || reg.active; + if (sw && sw.state !== 'activated') { + await new Promise((resolve) => { + sw!.addEventListener('statechange', function handler() { + if (sw!.state === 'activated') { + sw!.removeEventListener('statechange', handler); + resolve(); + } + }); + }); + } + if (!navigator.serviceWorker.controller) { + await new Promise((resolve) => { + navigator.serviceWorker.addEventListener( + 'controllerchange', + () => resolve(), + { once: true }, + ); + }); + } + })(); + return swReady; +} + +// Sets up a service worker that intercepts requests to http://test-realm/ +// and relays them to the VirtualNetwork so that browser-native resource loads +// (which bypass VirtualNetwork) can reach the test realm's files. +export function setupTestRealmServiceWorker(hooks: NestedHooks) { + let handler: ((event: MessageEvent) => void) | undefined; + + hooks.beforeEach(async function () { + if (!('serviceWorker' in navigator)) { + return; + } + await ensureRegistered(); + + let network = getService('network') as NetworkService; + handler = async (event: MessageEvent) => { + if (event.data?.type !== 'test-realm-fetch') { + return; + } + let port = event.ports[0]; + if (!port) { + return; + } + try { + let response = await network.virtualNetwork.fetch(event.data.url); + let body = await response.arrayBuffer(); + let headers: Record = {}; + response.headers.forEach((v, k) => { + headers[k] = v; + }); + port.postMessage({ status: response.status, headers, body }, [body]); + } catch { + port.postMessage({ status: 500, headers: {}, body: null }); + } + }; + navigator.serviceWorker.addEventListener('message', handler); + }); + + hooks.afterEach(function () { + if (handler) { + navigator.serviceWorker.removeEventListener('message', handler); + handler = undefined; + } + }); +} diff --git a/packages/host/tests/integration/components/card-basics-test.gts b/packages/host/tests/integration/components/card-basics-test.gts index 6d6b782d833..7b530fe477b 100644 --- a/packages/host/tests/integration/components/card-basics-test.gts +++ b/packages/host/tests/integration/components/card-basics-test.gts @@ -715,7 +715,7 @@ module('Integration | card-basics', function (hooks) { mockMatrixUtils, contents: { 'test-cards.gts': { Gallery }, - 'hero.png': 'mock image bytes', + 'hero.txt': 'some file content', 'Gallery/hero.json': { data: { type: 'card', @@ -725,10 +725,10 @@ module('Integration | card-basics', function (hooks) { relationships: { hero: { links: { - self: `${testRealmURL}hero.png`, + self: `${testRealmURL}hero.txt`, }, data: { - id: `${testRealmURL}hero.png`, + id: `${testRealmURL}hero.txt`, type: 'file-meta', }, }, @@ -752,13 +752,13 @@ module('Integration | card-basics', function (hooks) { await waitUntil(() => document .querySelector('[data-test-gallery-fitted]') - ?.textContent?.includes('hero.png'), + ?.textContent?.includes('hero.txt'), ); assert .dom('[data-test-gallery-fitted]') .includesText( - 'hero.png', + 'hero.txt', 'FileDef renders delegated view from file meta', ); }); @@ -781,8 +781,8 @@ module('Integration | card-basics', function (hooks) { mockMatrixUtils, contents: { 'test-cards.gts': { Gallery }, - 'first.png': 'first mock image', - 'second.png': 'second mock image', + 'first.txt': 'first file content', + 'second.txt': 'second file content', 'Gallery/attachments.json': { data: { type: 'card', @@ -792,19 +792,19 @@ module('Integration | card-basics', function (hooks) { relationships: { 'attachments.0': { links: { - self: `${testRealmURL}first.png`, + self: `${testRealmURL}first.txt`, }, data: { - id: `${testRealmURL}first.png`, + id: `${testRealmURL}first.txt`, type: 'file-meta', }, }, 'attachments.1': { links: { - self: `${testRealmURL}second.png`, + self: `${testRealmURL}second.txt`, }, data: { - id: `${testRealmURL}second.png`, + id: `${testRealmURL}second.txt`, type: 'file-meta', }, }, @@ -830,7 +830,7 @@ module('Integration | card-basics', function (hooks) { document.querySelector( '[data-test-plural-view-field="attachments"]', )?.textContent ?? ''; - return text.includes('first.png') && text.includes('second.png'); + return text.includes('first.txt') && text.includes('second.txt'); }); assert @@ -839,13 +839,13 @@ module('Integration | card-basics', function (hooks) { assert .dom('[data-test-plural-view-field="attachments"]') .includesText( - 'first.png', + 'first.txt', 'FileDef renders delegated view from file meta', ); assert .dom('[data-test-plural-view-field="attachments"]') .includesText( - 'second.png', + 'second.txt', 'FileDef renders delegated view from file meta', ); }); diff --git a/packages/realm-server/tests/card-endpoints-test.ts b/packages/realm-server/tests/card-endpoints-test.ts index cef6e35019b..6853873a194 100644 --- a/packages/realm-server/tests/card-endpoints-test.ts +++ b/packages/realm-server/tests/card-endpoints-test.ts @@ -44,6 +44,55 @@ function parseSearchQuery(searchURL: URL) { return parse(searchURL.searchParams.toString()) as Record; } +// Create minimal valid PNG bytes for testing +function makeMinimalPng(): Uint8Array { + let signature = [137, 80, 78, 71, 13, 10, 26, 10]; + let ihdrData = new Uint8Array(13); + let ihdrView = new DataView(ihdrData.buffer); + ihdrView.setUint32(0, 1); // width + ihdrView.setUint32(4, 1); // height + ihdrData[8] = 8; // bit depth + ihdrData[9] = 2; // color type (RGB) + let ihdrChunk = buildPngChunk('IHDR', ihdrData); + let idatData = new Uint8Array([ + 0x08, 0xd7, 0x01, 0x00, 0x00, 0xff, 0xff, 0x00, 0x01, 0x00, 0x01, + ]); + let idatChunk = buildPngChunk('IDAT', idatData); + let iendChunk = buildPngChunk('IEND', new Uint8Array(0)); + let totalLength = + signature.length + ihdrChunk.length + idatChunk.length + iendChunk.length; + let png = new Uint8Array(totalLength); + let offset = 0; + png.set(signature, offset); + offset += signature.length; + png.set(ihdrChunk, offset); + offset += ihdrChunk.length; + png.set(idatChunk, offset); + offset += idatChunk.length; + png.set(iendChunk, offset); + return png; +} + +function buildPngChunk(type: string, data: Uint8Array): Uint8Array { + let chunk = new Uint8Array(4 + 4 + data.length + 4); + let view = new DataView(chunk.buffer); + view.setUint32(0, data.length); + for (let i = 0; i < 4; i++) { + chunk[4 + i] = type.charCodeAt(i); + } + chunk.set(data, 8); + let crc = 0xffffffff; + let crcData = chunk.slice(4, 8 + data.length); + for (let i = 0; i < crcData.length; i++) { + crc ^= crcData[i]!; + for (let j = 0; j < 8; j++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + } + view.setUint32(8 + data.length, (crc ^ 0xffffffff) >>> 0); + return chunk; +} + module(basename(__filename), function () { module('Realm-specific Endpoints | card URLs', function (hooks) { let realmURL = new URL('http://127.0.0.1:4444/test/'); @@ -197,7 +246,7 @@ module(basename(__filename), function () { test('includes FileDef resources for file links in included payload', async function (assert) { let { testRealm: realm, request } = getRealmSetup(); - let writes = new Map([ + let writes = new Map([ [ 'gallery.gts', ` @@ -241,9 +290,9 @@ module(basename(__filename), function () { }, }), ], - ['hero.png', 'mock hero image'], - ['first.png', 'mock first image'], - ['second.png', 'mock second image'], + ['hero.png', makeMinimalPng()], + ['first.png', makeMinimalPng()], + ['second.png', makeMinimalPng()], ]); await realm.writeMany(writes); @@ -279,8 +328,8 @@ module(basename(__filename), function () { assert.strictEqual(hero?.attributes?.name, 'hero.png'); assert.strictEqual(hero?.attributes?.contentType, 'image/png'); assert.deepEqual(hero?.meta?.adoptsFrom, { - module: `${baseRealm.url}file-api`, - name: 'FileDef', + module: `${baseRealm.url}png-image-def`, + name: 'PngDef', }); assert.deepEqual( diff --git a/packages/runtime-common/index-runner.ts b/packages/runtime-common/index-runner.ts index 8a28eae754f..8c203ad11a0 100644 --- a/packages/runtime-common/index-runner.ts +++ b/packages/runtime-common/index-runner.ts @@ -58,6 +58,34 @@ const FILEDEF_CODE_REF_BY_EXTENSION: Record = { module: `${baseRealm.url}markdown-file-def`, name: 'MarkdownDef', }, + '.png': { + module: `${baseRealm.url}png-image-def`, + name: 'PngDef', + }, + '.jpg': { + module: `${baseRealm.url}jpg-image-def`, + name: 'JpgDef', + }, + '.jpeg': { + module: `${baseRealm.url}jpg-image-def`, + name: 'JpgDef', + }, + '.svg': { + module: `${baseRealm.url}svg-image-def`, + name: 'SvgDef', + }, + '.gif': { + module: `${baseRealm.url}gif-image-def`, + name: 'GifDef', + }, + '.webp': { + module: `${baseRealm.url}webp-image-def`, + name: 'WebpDef', + }, + '.avif': { + module: `${baseRealm.url}avif-image-def`, + name: 'AvifDef', + }, '.mismatch': { module: './filedef-mismatch', name: 'FileDef' }, }; const BASE_FILE_DEF_CODE_REF: ResolvedCodeRef = { diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 513d2f1752b..08da2c9e411 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -1677,7 +1677,11 @@ export class Realm { if (!hasExecutableExtension(fileRef.path)) { return { kind: 'non-module', - response: await this.serveLocalFile(request, fileRef, requestContext), + response: await this.serveLocalFile(request, fileRef, requestContext, { + defaultHeaders: { + 'content-type': inferContentType(fileRef.path), + }, + }), }; } diff --git a/packages/runtime-common/stream.ts b/packages/runtime-common/stream.ts index 32ce7ee06e8..7d5f42ffe01 100644 --- a/packages/runtime-common/stream.ts +++ b/packages/runtime-common/stream.ts @@ -59,6 +59,41 @@ export async function byteStreamToUint8Array( return await webStreamToBytes(stream); } +export async function readFirstBytes( + stream: ByteStream, + n: number, +): Promise { + if (stream instanceof Uint8Array) { + return stream.slice(0, n); + } + let reader = stream.getReader(); + let chunks: Uint8Array[] = []; + let total = 0; + try { + while (total < n) { + let { done, value } = await reader.read(); + if (done || !value) { + break; + } + chunks.push(value); + total += value.length; + } + } finally { + reader.releaseLock(); + stream.cancel().catch(() => {}); + } + if (chunks.length === 1) { + return chunks[0]!.slice(0, n); + } + let merged = new Uint8Array(total); + let offset = 0; + for (let chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + return merged.slice(0, n); +} + export async function fileContentToText({ content, }: Pick): Promise {