basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import * as fs from 'node:fs';8import * as os from 'node:os';9import * as path from 'node:path';10import lockfile from 'proper-lockfile';11import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';12import {13 appendChannelMemory,14 CHANNEL_MEMORY_FILE_NAME,15 clearChannelMemory,16 getChannelMemoryFilePath,17 MAX_CHANNEL_MEMORY_BYTES,18 readChannelMemory,19 type ChannelMemoryTarget,20} from './channel-memory.js';21 22describe('channel memory', () => {23 const originalQwenHome = process.env['QWEN_HOME'];24 let qwenHome: string;25 26 beforeEach(() => {27 qwenHome = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-channel-memory-'));28 process.env['QWEN_HOME'] = qwenHome;29 });30 31 afterEach(() => {32 if (originalQwenHome === undefined) {33 delete process.env['QWEN_HOME'];34 } else {35 process.env['QWEN_HOME'] = originalQwenHome;36 }37 fs.rmSync(qwenHome, { recursive: true, force: true });38 });39 40 it('returns a path under QWEN_HOME ending with CHANNEL.md', () => {41 const filePath = getChannelMemoryFilePath({42 channelName: 'prod',43 chatId: 'chat-1',44 });45 46 expect(filePath.startsWith(qwenHome + path.sep)).toBe(true);47 expect(filePath.endsWith(path.join('', CHANNEL_MEMORY_FILE_NAME))).toBe(48 true,49 );50 });51 52 it('keeps channel names and chat/thread identifiers safe', () => {53 const filePath = getChannelMemoryFilePath({54 channelName: '../prod/channel',55 chatId: 'raw-chat-id',56 threadId: 'raw-thread-id',57 });58 const relativePath = path.relative(qwenHome, filePath);59 60 expect(relativePath.split(path.sep)).not.toContain('..');61 expect(filePath).not.toContain('raw-chat-id');62 expect(filePath).not.toContain('raw-thread-id');63 });64 65 it('keeps a readable channel-name slug in the path', () => {66 const filePath = getChannelMemoryFilePath({67 channelName: 'team..bot',68 chatId: 'chat-1',69 });70 const relativeSegments = path.relative(qwenHome, filePath).split(path.sep);71 72 expect(relativeSegments[2]).toMatch(/^team\.\.bot-[a-f0-9]{16}$/u);73 });74 75 it.each(['.', '..'])(76 'does not use exact %s as the channel directory segment',77 (channelName) => {78 const filePath = getChannelMemoryFilePath({79 channelName,80 chatId: 'chat-1',81 });82 const relativePath = path.relative(qwenHome, filePath);83 const relativeSegments = relativePath.split(path.sep);84 85 expect(filePath.startsWith(qwenHome + path.sep)).toBe(true);86 expect(relativeSegments).not.toContain('.');87 expect(relativeSegments).not.toContain('..');88 expect(relativeSegments[0]).toBe('channels');89 expect(relativeSegments[1]).toBe('memory');90 expect(relativeSegments[2]).toMatch(/^[._]+-[a-f0-9]{16}$/u);91 },92 );93 94 it('uses different paths for colliding sanitized channel names', () => {95 const first = getChannelMemoryFilePath({96 channelName: 'ops/alerts',97 chatId: 'chat-1',98 });99 const second = getChannelMemoryFilePath({100 channelName: 'ops alerts',101 chatId: 'chat-1',102 });103 104 expect(first).not.toBe(second);105 });106 107 it('uses different paths for different thread ids', () => {108 const target: ChannelMemoryTarget = {109 channelName: 'prod',110 chatId: 'chat-1',111 };112 113 expect(114 getChannelMemoryFilePath({ ...target, threadId: 'thread-1' }),115 ).not.toBe(getChannelMemoryFilePath({ ...target, threadId: 'thread-2' }));116 });117 118 it('appends entries and reads the exact content', async () => {119 const target: ChannelMemoryTarget = {120 channelName: 'prod',121 chatId: 'chat-1',122 };123 124 await appendChannelMemory(target, 'Use staging cluster by default.');125 await appendChannelMemory(target, 'Ask before running deploy commands.');126 127 await expect(readChannelMemory(target)).resolves.toBe(128 'Use staging cluster by default.\nAsk before running deploy commands.\n',129 );130 });131 132 it('does not create memory for whitespace-only appends', async () => {133 const target: ChannelMemoryTarget = {134 channelName: 'prod',135 chatId: 'chat-1',136 };137 138 const result = await appendChannelMemory(target, ' \n\t ');139 140 expect(result).toEqual({141 changed: false,142 filePath: getChannelMemoryFilePath(target),143 });144 await expect(readChannelMemory(target)).resolves.toBe('');145 });146 147 it('clears memory when present', async () => {148 const target: ChannelMemoryTarget = {149 channelName: 'prod',150 chatId: 'chat-1',151 };152 153 await appendChannelMemory(target, 'Use staging cluster by default.');154 await expect(clearChannelMemory(target)).resolves.toEqual({155 changed: true,156 filePath: getChannelMemoryFilePath(target),157 });158 await expect(readChannelMemory(target)).resolves.toBe('');159 });160 161 it('reports no change when clearing missing memory', async () => {162 const target: ChannelMemoryTarget = {163 channelName: 'prod',164 chatId: 'chat-1',165 };166 167 await expect(clearChannelMemory(target)).resolves.toEqual({168 changed: false,169 filePath: getChannelMemoryFilePath(target),170 });171 });172 173 it('rejects writes over the maximum size', async () => {174 await expect(175 appendChannelMemory(176 { channelName: 'prod', chatId: 'chat-1' },177 'a'.repeat(MAX_CHANNEL_MEMORY_BYTES),178 ),179 ).rejects.toThrow('Channel memory exceeds maximum size');180 });181 182 it('continues appends after a rejected append', async () => {183 const target: ChannelMemoryTarget = {184 channelName: 'prod',185 chatId: 'chat-1',186 };187 188 await expect(189 appendChannelMemory(target, 'a'.repeat(MAX_CHANNEL_MEMORY_BYTES)),190 ).rejects.toThrow('Channel memory exceeds maximum size');191 await appendChannelMemory(target, 'after failure');192 193 await expect(readChannelMemory(target)).resolves.toBe('after failure\n');194 });195 196 it('retries append when the file disappears before locking', async () => {197 const target: ChannelMemoryTarget = {198 channelName: 'prod',199 chatId: 'chat-1',200 };201 const filePath = getChannelMemoryFilePath(target);202 const realLock = lockfile.lock.bind(lockfile);203 let deletedBeforeLock = false;204 const lockSpy = vi205 .spyOn(lockfile, 'lock')206 .mockImplementation(async (targetPath, options) => {207 if (!deletedBeforeLock && targetPath === filePath) {208 deletedBeforeLock = true;209 fs.rmSync(filePath, { force: true });210 throw Object.assign(new Error('missing'), { code: 'ENOENT' });211 }212 return realLock(targetPath, options);213 });214 215 try {216 await expect(appendChannelMemory(target, 'after clear')).resolves.toEqual(217 {218 changed: true,219 filePath,220 },221 );222 await expect(readChannelMemory(target)).resolves.toBe('after clear\n');223 expect(lockSpy).toHaveBeenCalledTimes(2);224 } finally {225 lockSpy.mockRestore();226 }227 });228 229 it('keeps concurrent appends within the maximum size', async () => {230 const target: ChannelMemoryTarget = {231 channelName: 'prod',232 chatId: 'chat-1',233 };234 const firstEntry = 'a'.repeat(MAX_CHANNEL_MEMORY_BYTES - 3);235 await appendChannelMemory(target, firstEntry);236 237 const results = await Promise.allSettled([238 appendChannelMemory(target, 'b'),239 appendChannelMemory(target, 'c'),240 ]);241 242 expect(243 results.filter((result) => result.status === 'fulfilled'),244 ).toHaveLength(1);245 expect(246 results.filter((result) => result.status === 'rejected'),247 ).toHaveLength(1);248 expect(249 fs.statSync(getChannelMemoryFilePath(target)).size,250 ).toBeLessThanOrEqual(MAX_CHANNEL_MEMORY_BYTES);251 });252 253 it('serializes clear after pending appends', async () => {254 const target: ChannelMemoryTarget = {255 channelName: 'prod',256 chatId: 'chat-1',257 };258 259 const appends = Array.from({ length: 20 }, (_, index) =>260 appendChannelMemory(target, `entry ${index}`),261 );262 await Promise.all([...appends, clearChannelMemory(target)]);263 264 await expect(readChannelMemory(target)).resolves.toBe('');265 });266 267 it('reads oversized existing memory as empty', async () => {268 const target: ChannelMemoryTarget = {269 channelName: 'prod',270 chatId: 'chat-1',271 };272 const filePath = getChannelMemoryFilePath(target);273 fs.mkdirSync(path.dirname(filePath), { recursive: true });274 fs.writeFileSync(filePath, Buffer.alloc(MAX_CHANNEL_MEMORY_BYTES + 1));275 276 await expect(readChannelMemory(target)).resolves.toBe('');277 });278});279 