fix ci test

This commit is contained in:
LaZzyMan 2026-02-09 15:30:06 +08:00
parent 37c3a38bb1
commit d3dfc26dea
7 changed files with 186 additions and 42 deletions

View file

@ -7,7 +7,7 @@
import * as path from 'node:path'; import * as path from 'node:path';
import * as os from 'node:os'; import * as os from 'node:os';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { getProjectHash } from '../utils/paths.js'; import { getProjectHash, getLegacyProjectHash } from '../utils/paths.js';
export const QWEN_DIR = '.qwen'; export const QWEN_DIR = '.qwen';
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
@ -90,7 +90,25 @@ export class Storage {
getProjectTempDir(): string { getProjectTempDir(): string {
const hash = getProjectHash(this.getProjectRoot()); const hash = getProjectHash(this.getProjectRoot());
const tempDir = Storage.getGlobalTempDir(); const tempDir = Storage.getGlobalTempDir();
return path.join(tempDir, hash); const targetDir = path.join(tempDir, hash);
// Backward compatibility: On Windows, check if legacy directory exists
// and migrate it to the new normalized path
if (os.platform() === 'win32' && !fs.existsSync(targetDir)) {
const legacyHash = getLegacyProjectHash(this.getProjectRoot());
const legacyDir = path.join(tempDir, legacyHash);
if (fs.existsSync(legacyDir) && legacyHash !== hash) {
try {
// Attempt to rename/migrate the directory
fs.renameSync(legacyDir, targetDir);
} catch (_error) {
// Silent fallback: if migration fails, continue with the new path
}
}
}
return targetDir;
} }
ensureProjectTempDirExists(): void { ensureProjectTempDirExists(): void {
@ -108,7 +126,25 @@ export class Storage {
getHistoryDir(): string { getHistoryDir(): string {
const hash = getProjectHash(this.getProjectRoot()); const hash = getProjectHash(this.getProjectRoot());
const historyDir = path.join(Storage.getGlobalQwenDir(), 'history'); const historyDir = path.join(Storage.getGlobalQwenDir(), 'history');
return path.join(historyDir, hash); const targetDir = path.join(historyDir, hash);
// Backward compatibility: On Windows, check if legacy directory exists
// and migrate it to the new normalized path
if (os.platform() === 'win32' && !fs.existsSync(targetDir)) {
const legacyHash = getLegacyProjectHash(this.getProjectRoot());
const legacyDir = path.join(historyDir, legacyHash);
if (fs.existsSync(legacyDir) && legacyHash !== hash) {
try {
// Attempt to rename/migrate the directory
fs.renameSync(legacyDir, targetDir);
} catch (_error) {
// Silent fallback: if migration fails, continue with the new path
}
}
}
return targetDir;
} }
getWorkspaceSettingsPath(): string { getWorkspaceSettingsPath(): string {

View file

@ -21,11 +21,11 @@ import {
decodeTagName, decodeTagName,
} from './logger.js'; } from './logger.js';
import { Storage } from '../config/storage.js'; import { Storage } from '../config/storage.js';
import { getProjectHash } from '../utils/paths.js';
import { promises as fs, existsSync } from 'node:fs'; import { promises as fs, existsSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import type { Content } from '@google/genai'; import type { Content } from '@google/genai';
import crypto from 'node:crypto';
import os from 'node:os'; import os from 'node:os';
const GEMINI_DIR_NAME = '.qwen'; const GEMINI_DIR_NAME = '.qwen';
@ -34,7 +34,7 @@ const LOG_FILE_NAME = 'logs.json';
const CHECKPOINT_FILE_NAME = 'checkpoint.json'; const CHECKPOINT_FILE_NAME = 'checkpoint.json';
const projectDir = process.cwd(); const projectDir = process.cwd();
const hash = crypto.createHash('sha256').update(projectDir).digest('hex'); const hash = getProjectHash(projectDir);
const TEST_HOME_DIR = path.join(os.tmpdir(), 'qwen-core-logger-home'); const TEST_HOME_DIR = path.join(os.tmpdir(), 'qwen-core-logger-home');
let originalHome: string | undefined; let originalHome: string | undefined;

View file

@ -18,6 +18,7 @@ import {
shortenPath, shortenPath,
tildeifyPath, tildeifyPath,
getProjectHash, getProjectHash,
getLegacyProjectHash,
} from './paths.js'; } from './paths.js';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
@ -848,3 +849,55 @@ describe('getProjectHash', () => {
platformSpy.mockRestore(); platformSpy.mockRestore();
}); });
}); });
describe('getLegacyProjectHash', () => {
it('should always be case-sensitive regardless of platform', () => {
const platformSpy = vi.spyOn(os, 'platform');
// Test on Windows - should still be case-sensitive
platformSpy.mockReturnValue('win32');
const lowerCaseHash = getLegacyProjectHash('c:\\users\\test\\project');
const upperCaseHash = getLegacyProjectHash('C:\\USERS\\TEST\\PROJECT');
expect(lowerCaseHash).not.toBe(upperCaseHash);
// Test on Linux - should be case-sensitive
platformSpy.mockReturnValue('linux');
const lowerCaseHashLinux = getLegacyProjectHash('/home/user/project');
const upperCaseHashLinux = getLegacyProjectHash('/HOME/USER/PROJECT');
expect(lowerCaseHashLinux).not.toBe(upperCaseHashLinux);
platformSpy.mockRestore();
});
it('should generate different hash than getProjectHash on Windows with mixed case', () => {
const platformSpy = vi.spyOn(os, 'platform');
platformSpy.mockReturnValue('win32');
const mixedCasePath = 'C:\\Users\\Test\\Project';
const legacyHash = getLegacyProjectHash(mixedCasePath);
const newHash = getProjectHash(mixedCasePath);
// They should be different because getProjectHash normalizes to lowercase
expect(legacyHash).not.toBe(newHash);
// But both lowercase paths should match the new hash
const lowercaseNewHash = getProjectHash(mixedCasePath.toLowerCase());
expect(newHash).toBe(lowercaseNewHash);
platformSpy.mockRestore();
});
it('should match getProjectHash on non-Windows platforms', () => {
const platformSpy = vi.spyOn(os, 'platform');
platformSpy.mockReturnValue('linux');
const testPath = '/home/user/project';
const legacyHash = getLegacyProjectHash(testPath);
const newHash = getProjectHash(testPath);
// On non-Windows platforms, both should be identical
expect(legacyHash).toBe(newHash);
platformSpy.mockRestore();
});
});

View file

@ -202,6 +202,17 @@ export function getProjectHash(projectRoot: string): string {
return crypto.createHash('sha256').update(normalizedPath).digest('hex'); return crypto.createHash('sha256').update(normalizedPath).digest('hex');
} }
/**
* Generates a hash using the legacy algorithm (without case normalization).
* This is used for backward compatibility to locate session directories
* created before the case-insensitive fix on Windows.
* @param projectRoot The absolute path to the project's root directory.
* @returns A SHA256 hash of the project root path without normalization.
*/
export function getLegacyProjectHash(projectRoot: string): string {
return crypto.createHash('sha256').update(projectRoot).digest('hex');
}
/** /**
* Checks if a path is a subpath of another path. * Checks if a path is a subpath of another path.
* @param parentPath The parent path. * @param parentPath The parent path.

View file

@ -8,6 +8,10 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import {
getProjectHash,
getLegacyProjectHash,
} from '@qwen-code/qwen-code-core/src/utils/paths.js';
import type { QwenSession, QwenMessage } from './qwenSessionReader.js'; import type { QwenSession, QwenMessage } from './qwenSessionReader.js';
/** /**
@ -28,24 +32,36 @@ export class QwenSessionManager {
} }
/** /**
* Calculate project hash (same as CLI) * Get the session directory for a project with backward compatibility
* Qwen CLI uses SHA256 hash of the project path.
* On Windows, paths are case-insensitive, so we normalize to lowercase
* to ensure the same physical path always produces the same hash.
*/
private getProjectHash(workingDir: string): string {
// On Windows, normalize path to lowercase for case-insensitive matching
const normalizedPath =
os.platform() === 'win32' ? workingDir.toLowerCase() : workingDir;
return crypto.createHash('sha256').update(normalizedPath).digest('hex');
}
/**
* Get the session directory for a project
*/ */
private getSessionDir(workingDir: string): string { private getSessionDir(workingDir: string): string {
const projectHash = this.getProjectHash(workingDir); const projectHash = getProjectHash(workingDir);
return path.join(this.qwenDir, 'tmp', projectHash, 'chats'); const sessionDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats');
// Backward compatibility: On Windows, check if legacy directory exists
// and migrate it to the new normalized path
if (os.platform() === 'win32' && !fs.existsSync(sessionDir)) {
const legacyHash = getLegacyProjectHash(workingDir);
const legacySessionDir = path.join(
this.qwenDir,
'tmp',
legacyHash,
'chats',
);
if (fs.existsSync(legacySessionDir) && legacyHash !== projectHash) {
try {
// Migrate parent directory (hash directory, not just chats)
const newParentDir = path.join(this.qwenDir, 'tmp', projectHash);
const legacyParentDir = path.join(this.qwenDir, 'tmp', legacyHash);
fs.renameSync(legacyParentDir, newParentDir);
} catch (_error) {
// Silent fallback: if migration fails, continue with the new path
}
}
}
return sessionDir;
} }
/** /**
@ -92,7 +108,7 @@ export class QwenSessionManager {
// Create session object // Create session object
const session: QwenSession = { const session: QwenSession = {
sessionId, sessionId,
projectHash: this.getProjectHash(workingDir), projectHash: getProjectHash(workingDir),
startTime: messages[0]?.timestamp || new Date().toISOString(), startTime: messages[0]?.timestamp || new Date().toISOString(),
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
messages, messages,

View file

@ -8,7 +8,10 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import * as readline from 'readline'; import * as readline from 'readline';
import * as crypto from 'crypto'; import {
getProjectHash,
getLegacyProjectHash,
} from '@qwen-code/qwen-code-core/src/utils/paths.js';
export interface QwenMessage { export interface QwenMessage {
id: string; id: string;
@ -58,8 +61,35 @@ export class QwenSessionReader {
if (!allProjects && workingDir) { if (!allProjects && workingDir) {
// Current project only // Current project only
const projectHash = await this.getProjectHash(workingDir); const projectHash = getProjectHash(workingDir);
const chatsDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats'); const chatsDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats');
// Backward compatibility: On Windows, try legacy hash if new directory doesn't exist
if (os.platform() === 'win32' && !fs.existsSync(chatsDir)) {
const legacyHash = getLegacyProjectHash(workingDir);
const legacyChatsDir = path.join(
this.qwenDir,
'tmp',
legacyHash,
'chats',
);
if (fs.existsSync(legacyChatsDir) && legacyHash !== projectHash) {
try {
// Migrate parent directory
const newParentDir = path.join(this.qwenDir, 'tmp', projectHash);
const legacyParentDir = path.join(
this.qwenDir,
'tmp',
legacyHash,
);
fs.renameSync(legacyParentDir, newParentDir);
} catch (_error) {
// Silent fallback
}
}
}
const projectSessions = await this.readSessionsFromDir(chatsDir); const projectSessions = await this.readSessionsFromDir(chatsDir);
sessions.push(...projectSessions); sessions.push(...projectSessions);
} else { } else {
@ -177,19 +207,6 @@ export class QwenSessionReader {
return found; return found;
} }
/**
* Calculate project hash (needs to be consistent with Qwen CLI)
* Qwen CLI uses SHA256 hash of project path.
* On Windows, paths are case-insensitive, so we normalize to lowercase
* to ensure the same physical path always produces the same hash.
*/
private async getProjectHash(workingDir: string): Promise<string> {
// On Windows, normalize path to lowercase for case-insensitive matching
const normalizedPath =
os.platform() === 'win32' ? workingDir.toLowerCase() : workingDir;
return crypto.createHash('sha256').update(normalizedPath).digest('hex');
}
/** /**
* Get session title (based on first user message) * Get session title (based on first user message)
*/ */
@ -294,7 +311,7 @@ export class QwenSessionReader {
} }
const projectHash = cwd const projectHash = cwd
? await this.getProjectHash(cwd) ? getProjectHash(cwd)
: path.basename(path.dirname(path.dirname(filePath))); : path.basename(path.dirname(path.dirname(filePath)));
return { return {

View file

@ -18,10 +18,21 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..'); const projectRoot = path.resolve(__dirname, '..');
const projectHash = crypto
.createHash('sha256') /**
.update(projectRoot) * Generates a unique hash for a project based on its root path.
.digest('hex'); * On Windows, paths are case-insensitive, so we normalize to lowercase
* to ensure the same physical path always produces the same hash.
* This logic must match getProjectHash() in packages/core/src/utils/paths.ts
*/
function getProjectHash(projectRoot) {
// On Windows, normalize path to lowercase for case-insensitive matching
const normalizedPath =
os.platform() === 'win32' ? projectRoot.toLowerCase() : projectRoot;
return crypto.createHash('sha256').update(normalizedPath).digest('hex');
}
const projectHash = getProjectHash(projectRoot);
// User-level .gemini directory in home // User-level .gemini directory in home
const USER_GEMINI_DIR = path.join(os.homedir(), '.qwen'); const USER_GEMINI_DIR = path.join(os.homedir(), '.qwen');