400 lines
17 KiB
TypeScript
400 lines
17 KiB
TypeScript
import {describe, expect, test} from 'bun:test'
|
|
import {AwsLogsAdapter} from './AwsLogsAdapter'
|
|
|
|
/**
|
|
* Stubs the SDK client's `send`, dispatching on the command's constructor name.
|
|
* Matching the real SDK's *name* rather than the wire shape is deliberate — see
|
|
* .claude/memory/aws-sdk-error-names-differ-from-wire-codes.md.
|
|
*/
|
|
function stubClient(handlers: Record<string, (input: Record<string, unknown>) => unknown>) {
|
|
const calls: Array<{command: string; input: Record<string, unknown>}> = []
|
|
const client = {
|
|
async send(command: {constructor: {name: string}; input: Record<string, unknown>}) {
|
|
const name = command.constructor.name
|
|
calls.push({command: name, input: command.input})
|
|
const handler = handlers[name]
|
|
if (!handler) throw new Error(`unexpected command ${name}`)
|
|
return handler(command.input)
|
|
},
|
|
}
|
|
return {client: client as never, calls}
|
|
}
|
|
|
|
describe('AwsLogsAdapter resources', () => {
|
|
test('normalizes log groups as cloud resources', async () => {
|
|
const {client} = stubClient({
|
|
DescribeLogGroupsCommand: () => ({
|
|
logGroups: [{
|
|
logGroupName: '/floci/probe',
|
|
creationTime: 1785260691820,
|
|
arn: 'arn:aws:logs:us-east-1:000000000000:log-group:/floci/probe',
|
|
storedBytes: 0,
|
|
metricFilterCount: 0,
|
|
}],
|
|
}),
|
|
})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
await expect(adapter.list()).resolves.toMatchObject([{
|
|
id: '/floci/probe',
|
|
name: '/floci/probe',
|
|
cloud: 'aws',
|
|
service: 'logs',
|
|
type: 'log-group',
|
|
createdAt: new Date(1785260691820).toISOString(),
|
|
}])
|
|
})
|
|
|
|
// The runtime filters natively by prefix, so `search` must not be a
|
|
// client-side contains — that would silently disagree with the field label.
|
|
test('search is sent as logGroupNamePrefix', async () => {
|
|
const {client, calls} = stubClient({DescribeLogGroupsCommand: () => ({logGroups: []})})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
await adapter.list({search: '/floci'})
|
|
|
|
expect(calls[0].input).toMatchObject({logGroupNamePrefix: '/floci'})
|
|
})
|
|
|
|
test('get returns null for a group the runtime does not have', async () => {
|
|
const {client} = stubClient({DescribeLogGroupsCommand: () => ({logGroups: []})})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
await expect(adapter.get('/nope')).resolves.toBeNull()
|
|
})
|
|
|
|
// DescribeLogGroups is a prefix match, so a longer name starting with the
|
|
// requested one would otherwise be returned as if it were an exact hit.
|
|
test('get matches the name exactly, not by prefix', async () => {
|
|
const {client} = stubClient({
|
|
DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/floci/probe-two', creationTime: 1}]}),
|
|
})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
await expect(adapter.get('/floci/probe')).resolves.toBeNull()
|
|
})
|
|
|
|
test('create and delete issue the right commands', async () => {
|
|
const {client, calls} = stubClient({
|
|
CreateLogGroupCommand: () => ({}),
|
|
DeleteLogGroupCommand: () => ({}),
|
|
DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/floci/new', creationTime: 1}]}),
|
|
})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
await expect(adapter.create({values: {name: '/floci/new'}})).resolves.toMatchObject({id: '/floci/new'})
|
|
await adapter.delete('/floci/new')
|
|
|
|
expect(calls.map((call) => call.command)).toEqual([
|
|
'CreateLogGroupCommand',
|
|
'DescribeLogGroupsCommand',
|
|
'DeleteLogGroupCommand',
|
|
])
|
|
})
|
|
|
|
/**
|
|
* `creationTime` is the field the SDK models and real AWS returns. The
|
|
* local runtime sends `createdTime`, which the SDK deserializer discards —
|
|
* so createdAt is legitimately null against the emulator, and a stub that
|
|
* feeds `createdTime` is testing a shape the SDK can never produce. That
|
|
* mistake shipped here once already; this pins the real contract.
|
|
*/
|
|
test('reads the creation timestamp the SDK actually models', async () => {
|
|
const {client} = stubClient({
|
|
DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/a', creationTime: 1785260691820}]}),
|
|
})
|
|
|
|
await expect(new AwsLogsAdapter(client).list()).resolves.toMatchObject([
|
|
{createdAt: new Date(1785260691820).toISOString()},
|
|
])
|
|
})
|
|
|
|
test('a group with no modelled timestamp reports null rather than a bogus date', async () => {
|
|
const {client} = stubClient({
|
|
DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/a'}]}),
|
|
})
|
|
|
|
await expect(new AwsLogsAdapter(client).list()).resolves.toMatchObject([{createdAt: null}])
|
|
})
|
|
|
|
test('create rejects a blank name before calling the runtime', async () => {
|
|
const {client, calls} = stubClient({})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
await expect(adapter.create({values: {name: ' '}})).rejects.toThrow()
|
|
expect(calls).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('AwsLogsAdapter queryLogs', () => {
|
|
test('starts a query and maps result fields into row objects', async () => {
|
|
const {client, calls} = stubClient({
|
|
StartQueryCommand: () => ({queryId: 'q-1'}),
|
|
GetQueryResultsCommand: () => ({
|
|
status: 'Complete',
|
|
results: [
|
|
[{field: '@timestamp', value: '2026-01-01 00:00:00.000'}, {field: '@message', value: 'hello'}],
|
|
],
|
|
}),
|
|
})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
const result = await adapter.queryLogs('/floci/probe', {
|
|
queryString: 'fields @timestamp, @message',
|
|
startTime: 1000,
|
|
endTime: 2000,
|
|
limit: 20,
|
|
})
|
|
|
|
expect(result).toEqual({
|
|
queryId: 'q-1',
|
|
status: 'Complete',
|
|
rows: [{'@timestamp': '2026-01-01 00:00:00.000', '@message': 'hello'}],
|
|
})
|
|
expect(calls[0]).toMatchObject({
|
|
command: 'StartQueryCommand',
|
|
input: {logGroupName: '/floci/probe', startTime: 1000, endTime: 2000, queryString: 'fields @timestamp, @message', limit: 20},
|
|
})
|
|
expect(calls[1].command).toBe('GetQueryResultsCommand')
|
|
})
|
|
|
|
// FLOCI_SERVICES_CLOUDWATCHLOGS_QUERY_COMPLETION_DELAY_MS defaults to 0, so a
|
|
// real completion normally takes one GetQueryResults call — but a positive
|
|
// delay (or real AWS) makes the first call(s) come back Running, so the
|
|
// adapter must keep polling rather than returning a stale in-progress result.
|
|
test('polls until the query leaves Running/Scheduled', async () => {
|
|
let getCalls = 0
|
|
const {client, calls} = stubClient({
|
|
StartQueryCommand: () => ({queryId: 'q-2'}),
|
|
GetQueryResultsCommand: () => {
|
|
getCalls += 1
|
|
if (getCalls < 3) return {status: getCalls === 1 ? 'Scheduled' : 'Running', results: []}
|
|
return {status: 'Complete', results: [[{field: '@message', value: 'done'}]]}
|
|
},
|
|
})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
const result = await adapter.queryLogs('/floci/probe', {queryString: 'fields @message', startTime: 0, endTime: 1})
|
|
|
|
expect(result).toEqual({queryId: 'q-2', status: 'Complete', rows: [{'@message': 'done'}]})
|
|
expect(calls.filter((call) => call.command === 'GetQueryResultsCommand')).toHaveLength(3)
|
|
})
|
|
|
|
// The P1 this test pins: if the poll deadline is hit while the query is
|
|
// still Running/Scheduled, the caller must still get the queryId back —
|
|
// dropping it here would make that still-running query unrecoverable, even
|
|
// though Floci (or real AWS) keeps executing it server-side.
|
|
test('still returns the query id when the poll deadline is hit before completion', async () => {
|
|
const {client} = stubClient({
|
|
StartQueryCommand: () => ({queryId: 'q-3'}),
|
|
GetQueryResultsCommand: () => ({status: 'Running', results: []}),
|
|
})
|
|
const adapter = new AwsLogsAdapter(client, {timeoutMs: 30, intervalMs: 10})
|
|
|
|
const result = await adapter.queryLogs('/floci/probe', {queryString: 'fields @message', startTime: 0, endTime: 1})
|
|
|
|
expect(result.queryId).toBe('q-3')
|
|
expect(result.status).toBe('Running')
|
|
})
|
|
|
|
test('rejects a blank query string before calling the runtime', async () => {
|
|
const {client, calls} = stubClient({})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
await expect(adapter.queryLogs('/floci/probe', {queryString: ' ', startTime: 0, endTime: 1}))
|
|
.rejects.toThrow()
|
|
expect(calls).toEqual([])
|
|
})
|
|
|
|
test('raises when the runtime does not hand back a query id', async () => {
|
|
const {client} = stubClient({StartQueryCommand: () => ({})})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
await expect(adapter.queryLogs('/floci/probe', {queryString: 'fields @message', startTime: 0, endTime: 1}))
|
|
.rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('AwsLogsAdapter filterLogEvents', () => {
|
|
test('filters across streams and preserves event identity and pagination', async () => {
|
|
const {client, calls} = stubClient({
|
|
FilterLogEventsCommand: () => ({
|
|
events: [{eventId: 'event-1', logStreamName: 'stream-a', timestamp: 1785260000000, message: 'ERROR failed', ingestionTime: 1785260001000}],
|
|
nextToken: 'page-2',
|
|
}),
|
|
})
|
|
|
|
const page = await new AwsLogsAdapter(client).filterLogEvents('/floci/probe', {
|
|
filterPattern: 'ERROR',
|
|
logStreamNames: ['stream-a'],
|
|
startTime: 1000,
|
|
endTime: 2000,
|
|
}, {cursor: 'page-1', limit: 25})
|
|
|
|
expect(calls[0]).toMatchObject({command: 'FilterLogEventsCommand', input: {
|
|
logGroupName: '/floci/probe', filterPattern: 'ERROR', logStreamNames: ['stream-a'],
|
|
startTime: 1000, endTime: 2000, nextToken: 'page-1', limit: 25,
|
|
}})
|
|
expect(page).toEqual({
|
|
items: [{
|
|
id: 'event-1', collectionId: 'stream-a', timestamp: new Date(1785260000000).toISOString(),
|
|
body: {message: 'ERROR failed'}, metadata: {ingestionTime: new Date(1785260001000).toISOString()},
|
|
}],
|
|
nextCursor: 'page-2',
|
|
})
|
|
})
|
|
|
|
test('retains a continuation token on an empty page', async () => {
|
|
const {client} = stubClient({FilterLogEventsCommand: () => ({events: [], nextToken: 'keep-going'})})
|
|
await expect(new AwsLogsAdapter(client).filterLogEvents('/floci/probe', {}))
|
|
.resolves.toEqual({items: [], nextCursor: 'keep-going'})
|
|
})
|
|
})
|
|
|
|
describe('AwsLogsAdapter documents', () => {
|
|
test('lists log streams as collections, newest activity first', async () => {
|
|
const {client, calls} = stubClient({
|
|
DescribeLogStreamsCommand: () => ({
|
|
logStreams: [{
|
|
logStreamName: 'stream-a',
|
|
arn: 'arn:aws:logs:us-east-1:000000000000:log-group:/floci/probe:log-stream:stream-a',
|
|
creationTime: 1785260702176,
|
|
storedBytes: 70,
|
|
firstEventTimestamp: 1785260000000,
|
|
lastEventTimestamp: 1785260001000,
|
|
}],
|
|
nextToken: 'page-2',
|
|
}),
|
|
})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
const page = await adapter.documents.listCollections('/floci/probe')
|
|
|
|
expect(page).toMatchObject({
|
|
items: [{id: 'stream-a', name: 'stream-a', parentId: '/floci/probe'}],
|
|
nextCursor: 'page-2',
|
|
})
|
|
expect(calls[0].input).toMatchObject({
|
|
logGroupName: '/floci/probe',
|
|
orderBy: 'LastEventTime',
|
|
descending: true,
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Real AWS caps DescribeLogStreams Limit at 50 and rejects anything higher
|
|
* with a ValidationException. The SPI defaults to 100 and permits 1000, and
|
|
* the local runtime does not enforce the cap — so an unclamped value passes
|
|
* every local test and 400s in production. Encode the provider's rule.
|
|
*/
|
|
test('clamps the stream page size to the AWS maximum of 50', async () => {
|
|
const {client, calls} = stubClient({DescribeLogStreamsCommand: () => ({logStreams: []})})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
await adapter.documents.listCollections('/floci/probe')
|
|
await adapter.documents.listCollections('/floci/probe', {limit: 1000})
|
|
await adapter.documents.listCollections('/floci/probe', {limit: 25})
|
|
|
|
expect(calls.map((call) => call.input.limit)).toEqual([50, 50, 25])
|
|
})
|
|
|
|
test('reads log events as items, oldest first', async () => {
|
|
const {client, calls} = stubClient({
|
|
GetLogEventsCommand: () => ({
|
|
events: [
|
|
{timestamp: 1785260000000, message: 'hello one', ingestionTime: 1785260702192},
|
|
{timestamp: 1785260001000, message: 'hello two', ingestionTime: 1785260702192},
|
|
],
|
|
nextForwardToken: 'f/2',
|
|
nextBackwardToken: 'b/0',
|
|
}),
|
|
})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
const page = await adapter.documents.listItems('/floci/probe', 'stream-a')
|
|
|
|
// Real AWS gives events no id, so it is derived from timestamp and
|
|
// position. Two events sharing a millisecond still get distinct ids.
|
|
expect(page.items).toMatchObject([
|
|
{
|
|
id: '1785260000000-0',
|
|
collectionId: 'stream-a',
|
|
timestamp: new Date(1785260000000).toISOString(),
|
|
body: {message: 'hello one'},
|
|
},
|
|
{id: '1785260001000-1', collectionId: 'stream-a'},
|
|
])
|
|
expect(page.nextCursor).toBe('f/2')
|
|
// Without startFromHead the runtime returns the newest page, so paging
|
|
// forward from a fresh cursor would skip the history.
|
|
expect(calls[0].input).toMatchObject({startFromHead: true})
|
|
})
|
|
|
|
/**
|
|
* The termination rule. GetLogEvents echoes the supplied token back when
|
|
* there is nothing further, so a cursor loop that trusts a non-null token
|
|
* never ends. Verified against the runtime on 2026-07-28.
|
|
*/
|
|
test('stops paging when the runtime echoes the cursor back', async () => {
|
|
const {client} = stubClient({
|
|
GetLogEventsCommand: () => ({events: [], nextForwardToken: 'f/2', nextBackwardToken: 'b/2'}),
|
|
})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
const page = await adapter.documents.listItems('/floci/probe', 'stream-a', {cursor: 'f/2'})
|
|
|
|
expect(page.items).toEqual([])
|
|
expect(page.nextCursor).toBeNull()
|
|
})
|
|
|
|
test('an empty first page terminates too', async () => {
|
|
const {client} = stubClient({
|
|
GetLogEventsCommand: () => ({events: [], nextForwardToken: 'f/0', nextBackwardToken: 'b/0'}),
|
|
})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
await expect(adapter.documents.listItems('/floci/probe', 'stream-a')).resolves.toMatchObject({nextCursor: null})
|
|
})
|
|
|
|
test('creates and deletes a log stream', async () => {
|
|
const {client, calls} = stubClient({
|
|
CreateLogStreamCommand: () => ({}),
|
|
DeleteLogStreamCommand: () => ({}),
|
|
DescribeLogStreamsCommand: () => ({
|
|
logStreams: [{logStreamName: 'stream-b', creationTime: 1785260702176}],
|
|
}),
|
|
})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
await expect(adapter.documents.createCollection!('/floci/probe', {values: {name: 'stream-b'}}))
|
|
.resolves.toMatchObject({id: 'stream-b', parentId: '/floci/probe'})
|
|
await adapter.documents.deleteCollection!('/floci/probe', 'stream-b')
|
|
|
|
expect(calls.map((call) => call.command)).toEqual([
|
|
'CreateLogStreamCommand',
|
|
'DescribeLogStreamsCommand',
|
|
'DeleteLogStreamCommand',
|
|
])
|
|
})
|
|
|
|
// Provider behaviour, verified: a missing group raises, a missing stream
|
|
// returns an empty page. The contract mirrors that rather than smoothing it.
|
|
test('a missing stream yields an empty page rather than an error', async () => {
|
|
const {client} = stubClient({
|
|
GetLogEventsCommand: () => ({events: [], nextForwardToken: 'f/0', nextBackwardToken: 'b/0'}),
|
|
})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
await expect(adapter.documents.listItems('/floci/probe', 'nope')).resolves.toEqual({items: [], nextCursor: null})
|
|
})
|
|
|
|
test('the store advertises no item writes', () => {
|
|
const {client} = stubClient({})
|
|
const adapter = new AwsLogsAdapter(client)
|
|
|
|
expect(adapter.documents.putItem).toBeUndefined()
|
|
expect(adapter.documents.deleteItem).toBeUndefined()
|
|
expect(adapter.documents.queryItems).toBeUndefined()
|
|
})
|
|
})
|