From abfe65e656650b7fb5115a04393d4b4d501e42ff Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 18 Sep 2026 17:26:24 +0800 Subject: [PATCH] fix: scope API access remarks to provider records Fixes #267 --- src-tauri/src/app_settings.rs | 430 +++++++++++++++++++++++--- src-tauri/src/core_config/settings.rs | 45 ++- src-tauri/src/main.rs | 24 +- src-tauri/src/usage.rs | 23 ++ src/pages/ApiAccessPage.tsx | 56 +++- src/styles.css | 5 +- tests/providerConfig.test.ts | 21 ++ 7 files changed, 533 insertions(+), 71 deletions(-) diff --git a/src-tauri/src/app_settings.rs b/src-tauri/src/app_settings.rs index 5e5589b..4a34b74 100644 --- a/src-tauri/src/app_settings.rs +++ b/src-tauri/src/app_settings.rs @@ -45,21 +45,7 @@ pub(crate) fn resolve_api_access_remarks( let config = gui_config_state.snapshot()?; queries .into_iter() - .map(|query| { - validate_api_access_provider_section(&query.provider_section)?; - Ok(query - .api_keys - .iter() - .filter_map(|key| api_access_key_hash(key)) - .find_map(|hash| { - config.api_access_remarks.iter().find(|entry| { - entry.provider_section == query.provider_section - && entry.api_key_hash == hash - }) - }) - .map(|entry| entry.remark.clone()) - .unwrap_or_default()) - }) + .map(|query| resolve_api_access_remark(&config, &query)) .collect() } @@ -68,44 +54,402 @@ pub(crate) fn save_api_access_remark( update: ApiAccessRemarkUpdate, gui_config_state: tauri::State<'_, GuiConfigState>, ) -> Result<(), String> { - validate_api_access_provider_section(&update.provider_section)?; - let remark = update.remark.trim().to_string(); - validate_api_key_remark(&remark)?; - let previous_hashes = update - .previous_api_keys - .iter() - .filter_map(|key| api_access_key_hash(key)) - .collect::>(); - let next_hashes = update + gui_config_state.update(|config| apply_api_access_remark_update(config, update))?; + Ok(()) +} + +fn api_access_locator_identity( + provider_section: &str, + locator: &ApiAccessRemarkLocator, +) -> Option<(String, Vec)> { + let mut api_key_hashes = locator .api_keys .iter() .filter_map(|key| api_access_key_hash(key)) - .collect::>(); - let hashes_to_replace = previous_hashes - .union(&next_hashes) - .cloned() + .collect::>(); + api_key_hashes.sort(); + api_key_hashes.dedup(); + if api_key_hashes.is_empty() { + return None; + } + + let mut identity = Vec::new(); + for component in [ + provider_section.trim(), + locator.provider_name.trim(), + locator.base_url.trim(), + ] { + identity.extend_from_slice(&(component.len() as u64).to_be_bytes()); + identity.extend_from_slice(component.as_bytes()); + } + identity.extend_from_slice(&(api_key_hashes.len() as u64).to_be_bytes()); + for hash in &api_key_hashes { + identity.extend_from_slice(&(hash.len() as u64).to_be_bytes()); + identity.extend_from_slice(hash.as_bytes()); + } + Some((sha256_bytes(&identity), api_key_hashes)) +} + +fn resolve_api_access_remark( + config: &GuiConfigFile, + query: &ApiAccessRemarkQuery, +) -> Result { + validate_api_access_provider_section(&query.provider_section)?; + let Some((record_hash, api_key_hashes)) = + api_access_locator_identity(&query.provider_section, &query.locator) + else { + return Ok(String::new()); + }; + + let exact_entries = config.api_access_remarks.iter().filter(|entry| { + entry.provider_section == query.provider_section + && entry.record_hash == record_hash + && api_key_hashes.contains(&entry.api_key_hash) + }); + let mut exact_found = false; + let mut exact_remark = String::new(); + for entry in exact_entries { + exact_found = true; + if !entry.remark.is_empty() { + exact_remark = entry.remark.clone(); + break; + } + } + if exact_found { + return Ok(exact_remark); + } + + Ok(api_key_hashes + .iter() + .find_map(|hash| { + config.api_access_remarks.iter().find(|entry| { + entry.provider_section == query.provider_section + && entry.record_hash.is_empty() + && entry.api_key_hash == *hash + }) + }) + .map(|entry| entry.remark.clone()) + .unwrap_or_default()) +} + +fn apply_api_access_remark_update( + config: &mut GuiConfigFile, + update: ApiAccessRemarkUpdate, +) -> Result<(), String> { + validate_api_access_provider_section(&update.provider_section)?; + let remark = update.remark.trim().to_string(); + validate_api_key_remark(&remark)?; + let previous_records = update + .previous_records + .iter() + .filter_map(|locator| api_access_locator_identity(&update.provider_section, locator)) + .collect::>(); + let next_records = update + .records + .iter() + .filter_map(|locator| api_access_locator_identity(&update.provider_section, locator)) + .collect::>(); + let all_records = update + .all_records + .iter() + .filter_map(|locator| api_access_locator_identity(&update.provider_section, locator)) + .collect::>(); + let record_hashes_to_replace = previous_records + .iter() + .chain(next_records.iter()) + .map(|(record_hash, _)| record_hash.clone()) .collect::>(); let provider_section = update.provider_section; + let legacy_entries = config + .api_access_remarks + .iter() + .filter(|entry| entry.provider_section == provider_section && entry.record_hash.is_empty()) + .cloned() + .collect::>(); + let legacy_migrations = all_records + .iter() + .filter_map(|(record_hash, api_key_hashes)| { + let remark = api_key_hashes.iter().find_map(|api_key_hash| { + legacy_entries + .iter() + .find(|entry| entry.api_key_hash == *api_key_hash) + .map(|entry| entry.remark.clone()) + })?; + Some((record_hash.clone(), api_key_hashes.clone(), remark)) + }) + .collect::>(); - gui_config_state.update(|config| { - config.api_access_remarks.retain(|entry| { - entry.provider_section != provider_section - || !hashes_to_replace.contains(&entry.api_key_hash) - }); - if !remark.is_empty() { - config - .api_access_remarks - .extend(next_hashes.iter().map(|hash| GuiApiAccessRemark { - provider_section: provider_section.clone(), - api_key_hash: hash.clone(), - remark: remark.clone(), - })); + config.api_access_remarks.retain(|entry| { + entry.provider_section != provider_section + || (!entry.record_hash.is_empty() + && !record_hashes_to_replace.contains(&entry.record_hash)) + }); + let mut inserted_record_hashes = config + .api_access_remarks + .iter() + .filter(|entry| entry.provider_section == provider_section && !entry.record_hash.is_empty()) + .map(|entry| entry.record_hash.clone()) + .collect::>(); + for (record_hash, api_key_hashes) in next_records { + if !inserted_record_hashes.insert(record_hash.clone()) { + continue; } - Ok(()) - })?; + config + .api_access_remarks + .extend( + api_key_hashes + .into_iter() + .map(|api_key_hash| GuiApiAccessRemark { + provider_section: provider_section.clone(), + api_key_hash, + record_hash: record_hash.clone(), + remark: remark.clone(), + }), + ); + } + for (record_hash, api_key_hashes, legacy_remark) in legacy_migrations { + if !inserted_record_hashes.insert(record_hash.clone()) { + continue; + } + config + .api_access_remarks + .extend( + api_key_hashes + .into_iter() + .map(|api_key_hash| GuiApiAccessRemark { + provider_section: provider_section.clone(), + api_key_hash, + record_hash: record_hash.clone(), + remark: legacy_remark.clone(), + }), + ); + } Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + + fn locator(base_url: &str, api_keys: &[&str]) -> ApiAccessRemarkLocator { + ApiAccessRemarkLocator { + provider_name: String::new(), + base_url: base_url.to_string(), + api_keys: api_keys.iter().map(|key| (*key).to_string()).collect(), + } + } + + fn query(locator: ApiAccessRemarkLocator) -> ApiAccessRemarkQuery { + ApiAccessRemarkQuery { + provider_section: "codex-api-key".to_string(), + locator, + } + } + + fn update( + previous_records: Vec, + records: Vec, + all_records: Vec, + remark: &str, + ) -> ApiAccessRemarkUpdate { + ApiAccessRemarkUpdate { + provider_section: "codex-api-key".to_string(), + previous_records, + records, + all_records, + remark: remark.to_string(), + } + } + + #[test] + fn remarks_are_scoped_to_provider_records_that_share_an_api_key() { + let mut config = GuiConfigFile::default(); + let first = locator("https://first.example/v1", &["shared-key"]); + let second = locator("https://second.example/v1", &["shared-key"]); + + apply_api_access_remark_update( + &mut config, + update( + Vec::new(), + vec![first.clone()], + vec![first.clone()], + "first", + ), + ) + .unwrap(); + apply_api_access_remark_update( + &mut config, + update( + Vec::new(), + vec![second.clone()], + vec![first.clone(), second.clone()], + "second", + ), + ) + .unwrap(); + + assert_eq!( + resolve_api_access_remark(&config, &query(first.clone())).unwrap(), + "first" + ); + assert_eq!( + resolve_api_access_remark(&config, &query(second.clone())).unwrap(), + "second" + ); + + apply_api_access_remark_update( + &mut config, + update(vec![first.clone()], Vec::new(), vec![second.clone()], ""), + ) + .unwrap(); + assert_eq!( + resolve_api_access_remark(&config, &query(second)).unwrap(), + "second" + ); + } + + #[test] + fn record_scoped_remarks_override_legacy_key_scoped_remarks() { + let mut config = GuiConfigFile::default(); + let first = locator("https://first.example/v1", &["shared-key"]); + let second = locator("https://second.example/v1", &["shared-key"]); + config.api_access_remarks.push(GuiApiAccessRemark { + provider_section: "codex-api-key".to_string(), + api_key_hash: api_access_key_hash("shared-key").unwrap(), + record_hash: String::new(), + remark: "legacy".to_string(), + }); + + apply_api_access_remark_update( + &mut config, + update( + vec![first.clone()], + vec![first.clone()], + vec![first.clone(), second.clone()], + "updated", + ), + ) + .unwrap(); + assert_eq!( + resolve_api_access_remark(&config, &query(first.clone())).unwrap(), + "updated" + ); + assert_eq!( + resolve_api_access_remark(&config, &query(second.clone())).unwrap(), + "legacy" + ); + + apply_api_access_remark_update( + &mut config, + update( + vec![first.clone()], + vec![first.clone()], + vec![first.clone(), second.clone()], + "", + ), + ) + .unwrap(); + assert_eq!( + resolve_api_access_remark(&config, &query(first)).unwrap(), + "" + ); + assert_eq!( + resolve_api_access_remark(&config, &query(second)).unwrap(), + "legacy" + ); + assert!(config + .api_access_remarks + .iter() + .all(|entry| !entry.record_hash.is_empty())); + } + + #[test] + fn updating_a_single_legacy_record_removes_the_stale_key_mapping() { + let mut config = GuiConfigFile::default(); + let record = locator("https://api.example/v1", &["shared-key"]); + config.api_access_remarks.push(GuiApiAccessRemark { + provider_section: "codex-api-key".to_string(), + api_key_hash: api_access_key_hash("shared-key").unwrap(), + record_hash: String::new(), + remark: "legacy".to_string(), + }); + + apply_api_access_remark_update( + &mut config, + update( + vec![record.clone()], + vec![record.clone()], + vec![record.clone()], + "updated", + ), + ) + .unwrap(); + + assert_eq!( + resolve_api_access_remark(&config, &query(record)).unwrap(), + "updated" + ); + assert_eq!( + config.api_access_remark_for_source("codex", "shared-key"), + Some("updated") + ); + assert!(config + .api_access_remarks + .iter() + .all(|entry| !entry.record_hash.is_empty())); + } + + #[test] + fn legacy_remark_entries_deserialize_without_a_record_hash() { + let entry = toml::from_str::( + r#"provider-section = "codex-api-key" +api-key-hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +remark = "legacy" +"#, + ) + .unwrap(); + + assert!(entry.record_hash.is_empty()); + assert_eq!(entry.remark, "legacy"); + } + + #[test] + fn remark_command_payloads_accept_record_locators() { + let query = serde_json::from_value::(serde_json::json!({ + "providerSection": "openai-compatibility", + "providerName": "first", + "baseUrl": "https://api.example/v1", + "apiKeys": ["shared-key"] + })) + .unwrap(); + let update = serde_json::from_value::(serde_json::json!({ + "providerSection": "openai-compatibility", + "previousRecords": [{ + "providerName": "first", + "baseUrl": "https://api.example/v1", + "apiKeys": ["shared-key"] + }], + "records": [{ + "providerName": "second", + "baseUrl": "https://api.example/v1", + "apiKeys": ["shared-key"] + }], + "allRecords": [{ + "providerName": "second", + "baseUrl": "https://api.example/v1", + "apiKeys": ["shared-key"] + }], + "remark": "second" + })) + .unwrap(); + + assert_eq!(query.locator.provider_name, "first"); + assert_eq!(update.previous_records[0].provider_name, "first"); + assert_eq!(update.records[0].provider_name, "second"); + assert_eq!(update.all_records[0].provider_name, "second"); + } +} + #[tauri::command] pub(crate) fn set_app_locale( app: tauri::AppHandle, diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 27868bf..ae4a388 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -57,19 +57,28 @@ impl GuiConfigFile { ) -> Option<&str> { let hash = api_access_key_hash(source)?; let preferred_section = usage_provider_section(provider); - self.api_access_remarks - .iter() - .find(|entry| { - preferred_section == Some(entry.provider_section.as_str()) + let resolve = |section: Option<&str>| { + let mut resolved: Option> = None; + for entry in self.api_access_remarks.iter().filter(|entry| { + section.is_none_or(|section| entry.provider_section == section) && entry.api_key_hash == hash - && !entry.remark.is_empty() - }) - .or_else(|| { - self.api_access_remarks - .iter() - .find(|entry| entry.api_key_hash == hash && !entry.remark.is_empty()) - }) - .map(|entry| entry.remark.as_str()) + }) { + let candidate = (!entry.remark.is_empty()).then_some(entry.remark.as_str()); + match resolved { + None => resolved = Some(candidate), + Some(current) if current == candidate => {} + Some(_) => return Some(None), + } + } + resolved + }; + + if let Some(section) = preferred_section { + if let Some(remark) = resolve(Some(section)) { + return remark; + } + } + resolve(None).flatten() } } @@ -1815,6 +1824,9 @@ pub(crate) fn write_gui_config_to_path( Value::from(entry.provider_section.as_str()), ); table.insert("api-key-hash", Value::from(entry.api_key_hash.as_str())); + if !entry.record_hash.is_empty() { + table.insert("record-hash", Value::from(entry.record_hash.as_str())); + } table.insert("remark", Value::from(entry.remark.as_str())); api_access_remarks.push(Value::InlineTable(table)); } @@ -1864,6 +1876,15 @@ pub(crate) fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String> { return Err("API 接入备注的密钥指纹无效".to_string()); } + if !entry.record_hash.is_empty() + && (entry.record_hash.len() != 64 + || !entry + .record_hash + .chars() + .all(|character| character.is_ascii_hexdigit())) + { + return Err("API 接入备注的条目指纹无效".to_string()); + } validate_api_key_remark(&entry.remark)?; } validate_strong_management_secret_key(&config.management_secret_key)?; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1b327f7..53a45c5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -860,22 +860,34 @@ struct GuiApiKeyEntry { struct GuiApiAccessRemark { provider_section: String, api_key_hash: String, + #[serde(default)] + record_hash: String, remark: String, } -#[derive(Deserialize)] +#[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct ApiAccessRemarkQuery { - provider_section: String, +struct ApiAccessRemarkLocator { + provider_name: String, + base_url: String, api_keys: Vec, } -#[derive(Deserialize)] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ApiAccessRemarkQuery { + provider_section: String, + #[serde(flatten)] + locator: ApiAccessRemarkLocator, +} + +#[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ApiAccessRemarkUpdate { provider_section: String, - previous_api_keys: Vec, - api_keys: Vec, + previous_records: Vec, + records: Vec, + all_records: Vec, remark: String, } diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index f7b0f12..686d87f 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -4742,6 +4742,7 @@ mod tests { config.api_access_remarks.push(crate::GuiApiAccessRemark { provider_section: "codex-api-key".to_string(), api_key_hash: hash_text(key), + record_hash: String::new(), remark: "生产环境".to_string(), }); @@ -4756,6 +4757,28 @@ mod tests { ); } + #[test] + fn usage_source_masks_shared_keys_with_different_record_remarks() { + let key = "sk-1234567890abcdefghijklmnopqrstuvwxyz"; + let mut config = GuiConfigFile::default(); + for (record_hash, remark) in [ + ("a".repeat(64), "生产环境"), + ("b".repeat(64), "测试环境"), + ] { + config.api_access_remarks.push(crate::GuiApiAccessRemark { + provider_section: "codex-api-key".to_string(), + api_key_hash: hash_text(key), + record_hash, + remark: remark.to_string(), + }); + } + + assert_eq!( + usage_source_display(&config, "codex", key), + "sk-1••••wxyz" + ); + } + #[test] fn local_hour_key_uses_year_month_day_and_hour() { let record = sample_record("id", "2026-07-17T20:30:00+08:00", "model"); diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 821c18f..2f27f70 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -112,6 +112,12 @@ type ProviderRow = { remark: string; }; +export type ApiAccessRemarkLocator = { + providerName: string; + baseUrl: string; + apiKeys: string[]; +}; + const providerDragId = ( row: Pick, ) => { @@ -321,8 +327,28 @@ const rowFromRecord = ( }; }; -const providerRemarkIdentity = (section: ProviderSection, apiKeys: string[]) => - `${section}\u0000${apiKeys.join('\u0000')}`; +export const providerRemarkIdentity = ( + section: ProviderSection, + locator: ApiAccessRemarkLocator, +) => JSON.stringify([section, locator.providerName, locator.baseUrl, locator.apiKeys]); + +export const apiAccessRemarkLocatorFromRecord = ( + section: ProviderSection, + record: Record, +): ApiAccessRemarkLocator => { + const row = rowFromRecord(section, record, 0); + return { + providerName: readString(record, 'name'), + baseUrl: row.baseUrl, + apiKeys: row.apiKeys, + }; +}; + +const apiAccessRemarkLocatorFromRow = (row: ProviderRow): ApiAccessRemarkLocator => ({ + providerName: readString(row.record, 'name'), + baseUrl: row.baseUrl, + apiKeys: row.apiKeys, +}); const providerHealthIdentity = (row: ProviderRow) => [ row.section, @@ -922,12 +948,12 @@ export function ApiAccessPage() { void invoke('resolve_api_access_remarks', { queries: providerRows.map((row) => ({ providerSection: row.section, - apiKeys: row.apiKeys, + ...apiAccessRemarkLocatorFromRow(row), })), }).then((remarks) => { if (disposed) return; setApiAccessRemarks(Object.fromEntries(providerRows.map((row, index) => [ - providerRemarkIdentity(row.section, row.apiKeys), + providerRemarkIdentity(row.section, apiAccessRemarkLocatorFromRow(row)), remarks[index] ?? '', ]))); }).catch(() => { @@ -947,7 +973,9 @@ export function ApiAccessPage() { name: activeCategory === 'deepseek' ? t('apiAccess.provider.deepseek') : row.name, - remark: apiAccessRemarks[providerRemarkIdentity(row.section, row.apiKeys)] ?? '', + remark: apiAccessRemarks[ + providerRemarkIdentity(row.section, apiAccessRemarkLocatorFromRow(row)) + ] ?? '', })) .filter((row) => providerCategoryMatchesRecord(activeCategory, row.record, activeSection)) .filter((row) => { @@ -1107,8 +1135,13 @@ export function ApiAccessPage() { await invoke('save_api_access_remark', { update: { providerSection: activeSection, - previousApiKeys: editingRow?.apiKeys ?? [], - apiKeys: parsedApiKeys, + previousRecords: editingRow ? [apiAccessRemarkLocatorFromRow(editingRow)] : [], + records: recordsToSave.map((record) => ( + apiAccessRemarkLocatorFromRecord(activeSection, record) + )), + allRecords: nextList.map((record) => ( + apiAccessRemarkLocatorFromRecord(activeSection, record) + )), remark: draftToSave.remark, }, }); @@ -1135,11 +1168,16 @@ export function ApiAccessPage() { query: { 'api-key': row.apiKey, 'base-url': row.baseUrl }, }); } + const latestConfig = await managementApi.get('/config'); + const remainingRecords = sectionRecordsFromConfig(latestConfig, row.section); await invoke('save_api_access_remark', { update: { providerSection: row.section, - previousApiKeys: row.apiKeys, - apiKeys: [], + previousRecords: [apiAccessRemarkLocatorFromRow(row)], + records: [], + allRecords: remainingRecords.map((record) => ( + apiAccessRemarkLocatorFromRecord(row.section, record) + )), remark: '', }, }); diff --git a/src/styles.css b/src/styles.css index dd7d40f..583260b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -8129,9 +8129,12 @@ textarea:focus-visible { .usage-overview-layout { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.65fr); - grid-template-rows: auto minmax(180px, 1fr); + grid-template-rows: auto minmax(250px, 1fr); gap: 14px; align-items: stretch; + overflow-x: hidden; + overflow-y: auto; + scrollbar-gutter: stable; } .usage-stat-grid { diff --git a/tests/providerConfig.test.ts b/tests/providerConfig.test.ts index ab8d206..2477bfe 100644 --- a/tests/providerConfig.test.ts +++ b/tests/providerConfig.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test'; import { + apiAccessRemarkLocatorFromRecord, applyProviderRemarkIdentity, applyProviderPreset, buildProviderRecord, @@ -11,6 +12,7 @@ import { parseProviderApiKeys, providerCategoryMatchesRecord, providerRecordWithDisabledState, + providerRemarkIdentity, providerSectionOrder, reorderProviderRecords, resolveProviderRecordIndex, @@ -139,6 +141,25 @@ it('parses multiline API keys into unique trimmed entries', () => { expect(parseProviderApiKeys(' key-a\n\nkey-b\r\nkey-a ')).toEqual(['key-a', 'key-b']); }); +it('keeps remark identities separate for records that share an API key', () => { + const first = apiAccessRemarkLocatorFromRecord('codex-api-key', { + 'api-key': 'shared-key', + 'base-url': 'https://first.example/v1', + }); + const second = apiAccessRemarkLocatorFromRecord('codex-api-key', { + 'api-key': 'shared-key', + 'base-url': 'https://second.example/v1', + }); + + expect(first.apiKeys).toEqual(second.apiKeys); + expect(first.baseUrl).not.toBe(second.baseUrl); + expect(providerRemarkIdentity('codex-api-key', first)) + .not.toBe(providerRemarkIdentity('codex-api-key', second)); + expect(providerRemarkIdentity('codex-api-key', first)).toBe( + providerRemarkIdentity('codex-api-key', { ...first, apiKeys: [...first.apiKeys] }), + ); +}); + describe('API 接入配置合并', () => { it('固定使用 Codex、OpenAI、DeepSeek、Claude、Gemini 顺序且不包含 Vertex', () => { expect(providerSectionOrder).toEqual([