diff --git a/bun.lock b/bun.lock index daed5f4..9213ced 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "@rawback/cli", "dependencies": { "@inquirer/prompts": "^8.7.0", - "@rawback/ccapi-js": "1.1.0", + "@rawback/ccapi-js": "1.1.2", "@rawback/sdk": "0.3.3", "ink": "^7.1.1", "react": "^19.2.8", @@ -147,7 +147,7 @@ "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], - "@rawback/ccapi-js": ["@rawback/ccapi-js@1.1.0", "", {}, "sha512-d3TO23EyXCwgWKV/2R6AQqrQD/gIqw8uc/89/tZS7HiJoXTpflH3OqHdsQJjqV2ptXbeiOj0BVk+VZkKCC0j8w=="], + "@rawback/ccapi-js": ["@rawback/ccapi-js@1.1.2", "", {}, "sha512-xp93Qsepjd8fxF1XMOunt6weuRX7divdFx2jnsVIjCZMGZHlqt+esZ2IWpnL1dwMIlLDoWczHnxE/P4VHHtHWw=="], "@rawback/sdk": ["@rawback/sdk@0.3.3", "", { "dependencies": { "@graphql-typed-document-node/core": "3.2.0", "blurhash": "2.0.5", "exiftool-vendored": "37.0.0", "graphql": "17.0.2", "pino": "^10.3.1", "pino-roll": "^4.0.0", "proper-lockfile": "4.1.2", "ssh2": "1.17.0", "yaml": "2.9.0", "zod": "4.4.3" } }, "sha512-HWEsEDIQMQ+zMb/OgMHb1eNkpMEown9PbbVeuzCccEferWemnI35lkr1rIIvIxCXiLd581OlktQfPVds7srEMQ=="], diff --git a/docs/commands.md b/docs/commands.md index 552ca7f..98ec7ca 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -274,8 +274,11 @@ rawback camera contents delete [--force] [--json] ``` A **locator** is the string the camera returns from `contents list`; pass it back -verbatim. Newer bodies insert an extra path segment, which the CLI handles for -you. +verbatim. Bodies on CCAPI 1.4 (the EOS R6 Mark III, R5 Mark II, R1, …) file every +directory under a folder — `DCIM` for stills, `XFVC`/`CRM` for movie reels — which +`contents dirs` prints as part of each path. `list` takes the directory as a bare +name (`100CANON`, looked up on the card and preferring `DCIM`), as +`DCIM/100CANON`, or as a locator printed by `dirs`. `get` streams to disk rather than buffering, so a RAW file costs no memory. Point `--output` at a directory to keep the camera's own filename. An existing file is @@ -303,6 +306,8 @@ rawback camera events watch [--count ] [--duration ] [--json] rawback camera events clear [--force] [--json] ``` +`poll --wait` holds the request until something changes, in the style the +camera's CCAPI version takes (`timeout=long` from 1.1, `continue=on` on 1.0). `watch` streams changes until Ctrl-C, `--count`, or `--duration`. Every event carries `changedKeys`, which lists every key the camera reported, including ones the client does not model. diff --git a/package.json b/package.json index ef77858..cefd029 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@inquirer/prompts": "^8.7.0", - "@rawback/ccapi-js": "1.1.0", + "@rawback/ccapi-js": "1.1.2", "@rawback/sdk": "0.3.4", "ink": "^7.1.1", "react": "^19.2.8", diff --git a/src/camera-contents.ts b/src/camera-contents.ts index c91c519..d6f78cf 100644 --- a/src/camera-contents.ts +++ b/src/camera-contents.ts @@ -3,13 +3,13 @@ import { mkdir, stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { Writable } from 'node:stream' -import type { ContentDataKind, ContentLocator, ContentType, ContentsOrder } from '@rawback/ccapi-js' +import type { ContentDataKind, ContentType, ContentsOrder } from '@rawback/ccapi-js' import { CameraError } from './camera-errors.ts' +import { folderOption, parseContentLocator, resolveDirectory } from './camera-locators.ts' import { withCameraSession, type CameraCommandDependencies, - type CameraSession, type CameraTargetOptions, } from './camera-session.ts' import { cameraPrompts } from './camera.ts' @@ -20,37 +20,10 @@ import { type ContentsListRow, } from './features/camera/view.ts' -/** - * Locators are the strings the camera itself returns, so the ver140 `folder` - * segment never has to reach the user. Splitting after `/contents/` gives - * `storage/folder/directory/file` on ver140 and `storage/directory/file` below - * it — the same rule the desktop app uses. - */ -export function parseContentLocator(locator: string): ContentLocator { - const trimmed = locator.trim().replace(/\/+$/, '') - const afterContents = trimmed.split('/contents/')[1] ?? trimmed.replace(/^\/+/, '') - const segments = afterContents.split('/').filter((segment) => segment.length > 0) +export { parseContentLocator } from './camera-locators.ts' - if (segments.length === 4) { - const [storage, folder, directory, file] = segments as [string, string, string, string] - return { storage, folder, directory, file } - } - if (segments.length === 3) { - const [storage, directory, file] = segments as [string, string, string] - return { storage, directory, file } - } - throw new CameraError( - `Not a content locator: ${JSON.stringify(locator)}. ` + - 'Use one printed by rawback camera contents list.', - ) -} - -function listOptions( - session: CameraSession, - options: { type?: string; order?: string; page?: number }, -) { +function listOptions(options: { type?: string; order?: string; page?: number }) { return { - ...(session.folderSegment !== undefined ? { folder: session.folderSegment } : {}), ...(options.type !== undefined && options.type !== 'all' ? { type: options.type as ContentType } : {}), @@ -110,13 +83,15 @@ export async function runCameraContentsList( ): Promise { const ui = commandOutput(dependencies) await withCameraSession(options, dependencies, async (session) => { + const directory = await resolveDirectory(session, options.storage, options.directory) + const where = { ...listOptions(options), ...folderOption(directory) } if (options.all === true) { // The chunked form streams the whole listing rather than one page. const locators: string[] = [] for await (const page of session.client.contents.streamContents( - options.storage, - options.directory, - listOptions(session, options), + directory.storage, + directory.directory, + where, )) { locators.push(...page) } @@ -129,15 +104,16 @@ export async function runCameraContentsList( } const page = options.page ?? 1 - const [listing, counts] = await Promise.all([ - session.client.contents.listContents(options.storage, options.directory, { - ...listOptions(session, options), - page, - }), - session.client.contents - .getContentsNumber(options.storage, options.directory, listOptions(session, options)) - .catch(() => undefined), - ]) + // One after the other: a body serves one contents request at a time and + // answers `503` to a second that overlaps it. + const listing = await session.client.contents.listContents( + directory.storage, + directory.directory, + { ...where, page }, + ) + const counts = await session.client.contents + .getContentsNumber(directory.storage, directory.directory, where) + .catch(() => undefined) if (options.json === true) { ui.json({ diff --git a/src/camera-events.ts b/src/camera-events.ts index 999c223..e276730 100644 --- a/src/camera-events.ts +++ b/src/camera-events.ts @@ -33,7 +33,9 @@ export async function runCameraEventsPoll( await withCameraSession(options, dependencies, async (session) => { const event = await session.client.event.getPolling({ signal: session.signal, - ...(options.wait === true ? { continue: true } : {}), + // `hold` long-polls in the style the body's event/polling version takes: + // `continue=on` on ver100, `timeout=long` from ver110 on. + ...(options.wait === true ? { hold: true } : {}), ...(options.timeoutKind !== undefined ? { timeout: options.timeoutKind } : {}), }) diff --git a/src/camera-locators.ts b/src/camera-locators.ts new file mode 100644 index 0000000..c6abf89 --- /dev/null +++ b/src/camera-locators.ts @@ -0,0 +1,101 @@ +import { + parseContentLocator as parseCameraLocator, + parseDirectoryLocator, + type ContentLocator, + type DirectoryLocator, +} from '@rawback/ccapi-js' + +import { CameraError } from './camera-errors.ts' +import type { CameraSession } from './camera-session.ts' + +/** + * Locators are the strings the camera itself returns, so the ver140 folder + * (`DCIM` for stills) never has to be typed by hand. Accepts what `contents + * list` prints — a path or a full URL — and a bare `storage/[folder/]directory/file`. + */ +export function parseContentLocator(locator: string): ContentLocator { + const trimmed = locator.trim() + for (const candidate of [trimmed, `/contents/${trimmed.replace(/^\/+/, '')}`]) { + try { + return parseCameraLocator(candidate) + } catch { + // Try the bare form, then give up with a message that says what to pass. + } + } + throw new CameraError( + `Not a content locator: ${JSON.stringify(locator)}. ` + + 'Use one printed by rawback camera contents list.', + ) +} + +/** + * Where a directory argument lives on the card, with its ver140 folder. + * + * Accepts a locator printed by `contents dirs` + * (`/ccapi/ver140/contents/card1/DCIM/100CANON`), a `folder/directory` pair + * (`DCIM/100CANON`), or a bare directory name. A ver140 body files every + * directory under a folder that each contents path must include, so a bare + * name is looked up on the storage — preferring `DCIM`, where stills live — + * and passed through unchanged when the listing has no folders (ver130 and + * earlier) or does not contain it. + * + * `storage` is always the one the user chose: a locator naming another card is + * refused rather than followed, since `deleteDirectory` acts on whatever this + * returns. A failed lookup is reported as itself — a busy or unreachable + * camera must not turn into a folderless request that 404s for another reason. + */ +export async function resolveDirectory( + session: CameraSession, + storage: string, + directory: string, +): Promise { + const trimmed = directory.trim().replace(/^\/+|\/+$/g, '') + if (trimmed.includes('contents/')) { + let located: DirectoryLocator + try { + located = parseDirectoryLocator(trimmed) + } catch { + throw new CameraError( + `Not a directory locator: ${JSON.stringify(directory)}. ` + + 'Use one printed by rawback camera contents dirs.', + ) + } + if (located.storage !== storage) { + throw new CameraError( + `${JSON.stringify(directory)} is on ${located.storage}, not ${storage}. ` + + 'Pass the storage the locator names, or a directory name.', + ) + } + return located + } + + const parts = trimmed.split('/').filter((part) => part.length > 0) + if (parts.length === 2) { + const [folder, name] = parts as [string, string] + return { storage, folder, directory: name } + } + if (parts.length !== 1) { + throw new CameraError( + `Not a directory: ${JSON.stringify(directory)}. ` + + 'Pass a name like 100CANON, or DCIM/100CANON.', + ) + } + + const name = parts[0] as string + const { paths } = await session.client.contents.listDirectories(storage) + const matches = paths.flatMap((path) => { + try { + const located = parseDirectoryLocator(path) + return located.directory === name ? [located] : [] + } catch { + return [] + } + }) + const match = matches.find((located) => located.folder === 'DCIM') ?? matches[0] + return match ?? { storage, directory: name } +} + +/** The `folder` option a contents call takes, when the directory has one. */ +export function folderOption(directory: DirectoryLocator): { folder?: string } { + return directory.folder !== undefined ? { folder: directory.folder } : {} +} diff --git a/src/camera-registry.ts b/src/camera-registry.ts index 7bf2454..6e7e989 100644 --- a/src/camera-registry.ts +++ b/src/camera-registry.ts @@ -5,8 +5,8 @@ // diverge. // // Ported from the reference TUI in the @rawback/ccapi-js repo, with three -// changes: `run` takes the CameraSession (so contents entries can default the -// ver140 `folder` segment and entries can be gated on `supports()`), every +// changes: `run` takes the CameraSession (so contents entries can look up a +// directory's ver140 folder and entries can be gated on `supports()`), every // unsafe cast is replaced by a validating accessor, and the namespace order // lists all nine namespaces (the reference lists six, so the rest sorted first). // @@ -18,6 +18,7 @@ import { PICTURE_STYLES } from '@rawback/ccapi-js' import type { ContentLocator, GPSInfo, PictureStyleName } from '@rawback/ccapi-js' import { CameraError } from './camera-errors.ts' +import { folderOption, parseContentLocator, resolveDirectory } from './camera-locators.ts' import type { CameraSession } from './camera-session.ts' export type Args = Record @@ -230,22 +231,12 @@ const LOCATOR: Param = { } function locatorOf(args: Args): ContentLocator { - const raw = argString(args, 'locator') - const afterContents = raw.split('/contents/')[1] ?? raw.replace(/^\/+/, '') - const segments = afterContents.split('/').filter((segment) => segment.length > 0) - if (segments.length === 4) { - const [storage, folder, directory, file] = segments as [string, string, string, string] - return { storage, folder, directory, file } - } - if (segments.length === 3) { - const [storage, directory, file] = segments as [string, string, string] - return { storage, directory, file } - } - throw new CameraError(`Not a content locator: ${JSON.stringify(raw)}`) + return parseContentLocator(argString(args, 'locator')) } -function folderOptions(session: CameraSession) { - return session.folderSegment !== undefined ? { folder: session.folderSegment } : {} +/** A directory argument with its ver140 folder resolved — see `resolveDirectory`. */ +function directoryOf(session: CameraSession, args: Args) { + return resolveDirectory(session, argString(args, 'storage'), argString(args, 'directory')) } // ── catalogue ──────────────────────────────────────────────────────────────── @@ -769,18 +760,27 @@ export const REGISTRY: readonly ApiEntry[] = [ '4.7.3', 'GET', [STR('storage'), STR('directory'), NUM('page', false)], - (s, a) => - s.client.contents.listContents(argString(a, 'storage'), argString(a, 'directory'), { - ...folderOptions(s), + async (s, a) => { + const directory = await directoryOf(s, a) + return s.client.contents.listContents(directory.storage, directory.directory, { + ...folderOption(directory), ...(a.page !== undefined ? { page: argNumber(a, 'page') } : {}), - }), + }) + }, ), - api('contents.getContentsNumber', '4.7.3', 'GET', [STR('storage'), STR('directory')], (s, a) => - s.client.contents.getContentsNumber( - argString(a, 'storage'), - argString(a, 'directory'), - folderOptions(s), - ), + api( + 'contents.getContentsNumber', + '4.7.3', + 'GET', + [STR('storage'), STR('directory')], + async (s, a) => { + const directory = await directoryOf(s, a) + return s.client.contents.getContentsNumber( + directory.storage, + directory.directory, + folderOption(directory), + ) + }, ), api('contents.getContentInfo', '4.7.5', 'GET', [LOCATOR], (s, a) => s.client.contents.getContentInfo(locatorOf(a)), @@ -790,12 +790,14 @@ export const REGISTRY: readonly ApiEntry[] = [ '4.7.4', 'DELETE', [STR('storage'), STR('directory')], - (s, a) => - s.client.contents.deleteDirectory( - argString(a, 'storage'), - argString(a, 'directory'), - s.folderSegment, - ), + async (s, a) => { + const directory = await directoryOf(s, a) + return s.client.contents.deleteDirectory( + directory.storage, + directory.directory, + directory.folder, + ) + }, { mutates: true }, ), api( diff --git a/src/camera-session.ts b/src/camera-session.ts index 2dd68c0..456b163 100644 --- a/src/camera-session.ts +++ b/src/camera-session.ts @@ -16,9 +16,6 @@ import type { CommandOutput } from './ui/output.tsx' /** A cached discovery map older than this is re-read rather than trusted. */ const DISCOVERY_TTL_MS = 7 * 24 * 60 * 60 * 1000 -/** ver140 nests contents under an extra `folder` segment; earlier versions do not. */ -const FOLDER_SEGMENT_VERSION = 'ver140' - export interface CameraTarget { host: string port: number @@ -196,11 +193,6 @@ export class CameraSession { return this.#abort.signal } - /** ver140 inserts a `folder` segment into contents paths; earlier versions do not. */ - get folderSegment(): string | undefined { - return this.#apiVersion >= FOLDER_SEGMENT_VERSION ? 'folder' : undefined - } - get errorContext(): CameraErrorContext { return { host: this.target.host, diff --git a/src/cli.ts b/src/cli.ts index 910db13..ae78808 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2559,7 +2559,10 @@ export function createProgram(version: string, output = new CommandOutput()): Ar describe: 'storage name (dirs, list) or content locator (info, get, delete)', type: 'string', }) - .positional('b', { describe: 'directory name, for list', type: 'string' }) + .positional('b', { + describe: 'directory for list: 100CANON, DCIM/100CANON, or a locator from dirs', + type: 'string', + }) .option('type', { choices: [ 'all', diff --git a/test/camera-commands.test.ts b/test/camera-commands.test.ts index 621fbb1..d005af6 100644 --- a/test/camera-commands.test.ts +++ b/test/camera-commands.test.ts @@ -387,7 +387,7 @@ describe('camera shoot', () => { const camera = fakeCamera({ routes: { 'event/polling': { - addedcontents: ['/ccapi/ver140/contents/card1/folder/100CANON/IMG_0042.JPG'], + addedcontents: ['/ccapi/ver140/contents/card1/DCIM/100CANON/IMG_0042.JPG'], }, }, }) @@ -402,7 +402,7 @@ describe('camera shoot', () => { released: true, af: true, mode: 'auto', - addedContents: ['/ccapi/ver140/contents/card1/folder/100CANON/IMG_0042.JPG'], + addedContents: ['/ccapi/ver140/contents/card1/DCIM/100CANON/IMG_0042.JPG'], }) // Events are cleared first so addedContents reflects only this shot. const shutter = camera.requests.filter((request) => diff --git a/test/camera-contents.test.ts b/test/camera-contents.test.ts index 85240cf..dd92688 100644 --- a/test/camera-contents.test.ts +++ b/test/camera-contents.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { runCameraApi } from '../src/camera-api.ts' import { parseContentLocator, runCameraContentsDelete, @@ -63,10 +64,13 @@ function saved(version = 'ver140'): StoredCamera { } describe('parseContentLocator', () => { - test('reads a ver140 locator with a folder segment', () => { - expect( - parseContentLocator('/ccapi/ver140/contents/card1/folder/100CANON/IMG_0042.JPG'), - ).toEqual({ storage: 'card1', folder: 'folder', directory: '100CANON', file: 'IMG_0042.JPG' }) + test('reads the camera’s own folder from a ver140 locator', () => { + expect(parseContentLocator('/ccapi/ver140/contents/card1/DCIM/100CANON/IMG_0042.JPG')).toEqual({ + storage: 'card1', + folder: 'DCIM', + directory: '100CANON', + file: 'IMG_0042.JPG', + }) }) test('reads an older locator without one', () => { @@ -109,21 +113,79 @@ describe('camera contents', () => { expect(output.json()).toEqual({ storages: ['/ccapi/ver140/contents/card1'] }) }) - test('a ver140 listing sends the folder segment', async () => { + /** A ver140 card: directories under DCIM, movie reels beside them. */ + const R6_MARK_III_DIRECTORIES = { + path: [ + '/ccapi/ver140/contents/card1/XFVC/100CANON', + '/ccapi/ver140/contents/card1/DCIM/100CANON', + '/ccapi/ver140/contents/card1/DCIM/101CANON', + ], + } + + test.each(['100CANON', 'DCIM/100CANON', '/ccapi/ver140/contents/card1/DCIM/100CANON'])( + 'a ver140 listing of %p goes through the DCIM folder', + async (directory) => { + const { store } = await temporaryStore() + await store.upsert(saved('ver140'), { makeDefault: true }) + const camera = fakeCamera({ + apiVersion: 'ver140', + paths: { 'ccapi/ver140/contents/card1': R6_MARK_III_DIRECTORIES }, + }) + const output = capture() + + await runCameraContentsList( + { storage: 'card1', directory, json: true }, + { store, processEnv: {}, fetch: camera.fetch, ...output.dependencies }, + ) + + const listings = camera.requests + .map((request) => request.url) + .filter((url) => url.includes('100CANON')) + expect(listings.map((url) => new URL(url).pathname)).toEqual([ + '/ccapi/ver140/contents/card1/DCIM/100CANON', + '/ccapi/ver140/contents/card1/DCIM/100CANON', + ]) + // Listed first, then counted — never both at once. + expect(new URL(listings[0] ?? '').searchParams.get('kind')).toBeNull() + expect(new URL(listings[1] ?? '').searchParams.get('kind')).toBe('number') + }, + ) + + test('refuses a directory locator on another card instead of following it', async () => { const { store } = await temporaryStore() await store.upsert(saved('ver140'), { makeDefault: true }) const camera = fakeCamera({ apiVersion: 'ver140' }) const output = capture() - await runCameraContentsList( - { storage: 'card1', directory: '100CANON', json: true }, - { store, processEnv: {}, fetch: camera.fetch, ...output.dependencies }, - ) + await expect( + runCameraApi( + { + id: 'contents.deleteDirectory', + arg: ['storage=card1', 'directory=/ccapi/ver140/contents/card2/DCIM/100CANON'], + force: true, + json: true, + }, + { store, processEnv: {}, fetch: camera.fetch, ...output.dependencies }, + ), + ).rejects.toThrow(/is on card2, not card1/) + expect(camera.requests.some((request) => request.method === 'DELETE')).toBe(false) + }) - // ver140 inserts `folder` as a path segment: contents//folder/ - expect( - camera.requests.some((request) => request.path.includes('contents/card1/folder/100CANON')), - ).toBe(true) + test('reports a failed directory lookup instead of listing without the folder', async () => { + const { store } = await temporaryStore() + await store.upsert(saved('ver140'), { makeDefault: true }) + // The storage listing fails; a folderless `contents/card1/100CANON` would + // only 404 for an unrelated reason and hide this one. + const camera = fakeCamera({ apiVersion: 'ver140', missing: ['contents/card1'] }) + const output = capture() + + await expect( + runCameraContentsList( + { storage: 'card1', directory: '100CANON', json: true }, + { store, processEnv: {}, fetch: camera.fetch, ...output.dependencies }, + ), + ).rejects.toThrow() + expect(camera.requests.some((request) => request.path.includes('100CANON'))).toBe(false) }) test('an older camera omits it', async () => { @@ -198,7 +260,7 @@ describe('camera contents', () => { await runCameraContentsGet( { - locator: '/ccapi/ver140/contents/card1/folder/100CANON/IMG_0042.JPG', + locator: '/ccapi/ver140/contents/card1/DCIM/100CANON/IMG_0042.JPG', output: target, json: true, }, @@ -221,7 +283,7 @@ describe('camera contents', () => { await expect( runCameraContentsGet( { - locator: '/ccapi/ver140/contents/card1/folder/100CANON/IMG_0042.JPG', + locator: '/ccapi/ver140/contents/card1/DCIM/100CANON/IMG_0042.JPG', output: target, json: true, }, @@ -252,7 +314,7 @@ describe('camera contents', () => { await runCameraContentsGet( { - locator: '/ccapi/ver140/contents/card1/folder/100CANON/IMG_0042.JPG', + locator: '/ccapi/ver140/contents/card1/DCIM/100CANON/IMG_0042.JPG', output: directory, json: true, }, @@ -269,7 +331,7 @@ describe('camera contents', () => { const output = capture() await runCameraContentsDelete( - { locator: '/ccapi/ver140/contents/card1/folder/100CANON/IMG_0042.JPG', json: true }, + { locator: '/ccapi/ver140/contents/card1/DCIM/100CANON/IMG_0042.JPG', json: true }, { store, processEnv: {}, @@ -291,7 +353,7 @@ describe('camera contents', () => { await runCameraContentsDelete( { - locator: '/ccapi/ver140/contents/card1/folder/100CANON/IMG_0042.JPG', + locator: '/ccapi/ver140/contents/card1/DCIM/100CANON/IMG_0042.JPG', force: true, json: true, }, @@ -300,6 +362,6 @@ describe('camera contents', () => { expect(output.json().deleted).toBe(true) const request = camera.requests.find((entry) => entry.method === 'DELETE') - expect(request?.path).toContain('card1/folder/100CANON/IMG_0042.JPG') + expect(request?.path).toContain('card1/DCIM/100CANON/IMG_0042.JPG') }) }) diff --git a/test/camera-session.test.ts b/test/camera-session.test.ts index 1b31932..d08fab2 100644 --- a/test/camera-session.test.ts +++ b/test/camera-session.test.ts @@ -428,31 +428,6 @@ describe('withCameraSession', () => { expect(camera.requested('shooting/liveview/multipart')).toBe(true) }) - test('folderSegment tracks the ver140 contents path quirk', async () => { - for (const [version, expected] of [ - ['ver140', 'folder'], - ['ver130', undefined], - ] as const) { - const { store } = await savedCamera({ - id: `192.168.0.1:8080`, - discovery: { - apiVersion: version, - cachedAt: new Date().toISOString(), - supportedAPIs: supportedAPIs(version), - }, - }) - const camera = fakeCamera({ apiVersion: version }) - - const segment = await withCameraSession( - {}, - { store, processEnv: {}, fetch: camera.fetch, ...silent() }, - async (session) => session.folderSegment, - ) - - expect(segment).toBe(expected) - } - }) - test('supports() answers from the discovery map', async () => { const { store } = await savedCamera({ discovery: { diff --git a/test/camera-stream.test.ts b/test/camera-stream.test.ts index 6476a5f..a192138 100644 --- a/test/camera-stream.test.ts +++ b/test/camera-stream.test.ts @@ -3,7 +3,11 @@ import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { runCameraEventsClear, runCameraEventsWatch } from '../src/camera-events.ts' +import { + runCameraEventsClear, + runCameraEventsPoll, + runCameraEventsWatch, +} from '../src/camera-events.ts' import { runCameraLiveviewStop, runCameraLiveviewStream } from '../src/camera-liveview.ts' import { cameraId, type StoredCamera } from '../src/camera-store.ts' import { @@ -230,6 +234,36 @@ describe('events watch', () => { }) }) +describe('events poll', () => { + test.each([ + ['ver110', '?timeout=long'], + ['ver100', '?continue=on'], + ])('--wait on a %s event endpoint holds with %s', async (version, query) => { + const { store } = await temporaryStore() + await store.upsert( + { + ...saved(), + discovery: { + apiVersion: version, + cachedAt: new Date().toISOString(), + supportedAPIs: supportedAPIs(version), + }, + }, + { makeDefault: true }, + ) + const camera = fakeCamera({ apiVersion: version }) + const output = capture() + + await runCameraEventsPoll( + { wait: true, json: true }, + { store, processEnv: {}, fetch: camera.fetch, ...output.dependencies }, + ) + + const poll = camera.requests.find((request) => request.path.endsWith('event/polling')) + expect(new URL(poll?.url ?? '').search).toBe(query) + }) +}) + describe('events clear', () => { test('--force clears polling without prompting', async () => { const { store } = await temporaryStore()