|
4 | 4 | *--------------------------------------------------------------------------------------------*/ |
5 | 5 |
|
6 | 6 | import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; |
| 7 | +import type * as vscode from 'vscode'; |
7 | 8 | import { IExperimentationService as ITASExperimentationService } from 'vscode-tas-client'; |
| 9 | +import { mock } from '../../../../util/common/test/simpleMock'; |
| 10 | +import { Event } from '../../../../util/vs/base/common/event'; |
8 | 11 | import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; |
9 | 12 | import { CopilotToken, createTestExtendedTokenInfo } from '../../../authentication/common/copilotToken'; |
10 | 13 | import { ICopilotTokenStore } from '../../../authentication/common/copilotTokenStore'; |
11 | 14 | import { IConfigurationService } from '../../../configuration/common/configurationService'; |
12 | 15 | import { IVSCodeExtensionContext } from '../../../extContext/common/extensionContext'; |
13 | 16 | import { ILogService } from '../../../log/common/logService'; |
| 17 | +import { FetchOptions, HeadersImpl, IFetcherService, Response } from '../../../networking/common/fetcherService'; |
14 | 18 | import { createPlatformServices, ITestingServicesAccessor } from '../../../test/node/services'; |
15 | 19 | import { TreatmentsChangeEvent } from '../../common/nullExperimentationService'; |
16 | | -import { BaseExperimentationService, TASClientDelegateFn, UserInfoStore } from '../../node/baseExperimentationService'; |
| 20 | +import { createTasFetch } from '../../vscode-node/tasFetch'; |
| 21 | +import { BaseExperimentationService, RevocationGate, TASClientDelegateFn, UserInfoStore } from '../../node/baseExperimentationService'; |
17 | 22 |
|
18 | 23 |
|
19 | 24 | function toExpectedTreatment(name: string, org: string | undefined, sku: string | undefined): string | undefined { |
@@ -45,6 +50,31 @@ class TestExperimentationService extends BaseExperimentationService { |
45 | 50 | } |
46 | 51 | } |
47 | 52 |
|
| 53 | +/** Captures the memento + revocation gate handed to each delegate generation. */ |
| 54 | +class RevocationTestExperimentationService extends BaseExperimentationService { |
| 55 | + public readonly captured: { memento: vscode.Memento; gate: RevocationGate }[]; |
| 56 | + |
| 57 | + constructor( |
| 58 | + @IVSCodeExtensionContext extensionContext: IVSCodeExtensionContext, |
| 59 | + @ICopilotTokenStore tokenStore: ICopilotTokenStore, |
| 60 | + @IConfigurationService configurationService: IConfigurationService, |
| 61 | + @ILogService logService: ILogService |
| 62 | + ) { |
| 63 | + const captured: { memento: vscode.Memento; gate: RevocationGate }[] = []; |
| 64 | + const delegateFn: TASClientDelegateFn = (memento, userInfoStore, gate) => { |
| 65 | + captured.push({ memento, gate }); |
| 66 | + return new MockTASExperimentationService(userInfoStore); |
| 67 | + }; |
| 68 | + |
| 69 | + super(delegateFn, extensionContext, tokenStore, configurationService, logService); |
| 70 | + this.captured = captured; |
| 71 | + } |
| 72 | + |
| 73 | + recreate(): void { |
| 74 | + this.recreateDelegate(); |
| 75 | + } |
| 76 | +} |
| 77 | + |
48 | 78 | class MockTASExperimentationService implements ITASExperimentationService { |
49 | 79 | private _initializePromise: Promise<void> | undefined; |
50 | 80 | private _initialFetch: Promise<void> | undefined; |
@@ -169,6 +199,27 @@ describe('ExP Service Tests', () => { |
169 | 199 | }); |
170 | 200 | }; |
171 | 201 |
|
| 202 | + it('revokes a superseded delegate so its late writes are dropped', async () => { |
| 203 | + const svc = accessor.get(IInstantiationService).createInstance(RevocationTestExperimentationService); |
| 204 | + const globalState = accessor.get(IVSCodeExtensionContext).globalState; |
| 205 | + |
| 206 | + expect(svc.captured.length).toBe(1); |
| 207 | + svc.recreate(); |
| 208 | + expect(svc.captured.length).toBe(2); |
| 209 | + |
| 210 | + const [gen1, gen2] = svc.captured; |
| 211 | + expect(gen1.gate.isRevoked).toBe(true); |
| 212 | + expect(gen2.gate.isRevoked).toBe(false); |
| 213 | + |
| 214 | + // A superseded (revoked) generation's writes are dropped; the current one's land. |
| 215 | + await gen1.memento.update('exp.revoke.test', 'stale'); |
| 216 | + expect(globalState.get('exp.revoke.test')).toBeUndefined(); |
| 217 | + await gen2.memento.update('exp.revoke.test', 'fresh'); |
| 218 | + expect(globalState.get('exp.revoke.test')).toBe('fresh'); |
| 219 | + |
| 220 | + svc.dispose(); |
| 221 | + }); |
| 222 | + |
172 | 223 | it('should return treatments based on copilot token', async () => { |
173 | 224 | await expService.hasTreatments(); |
174 | 225 | let expectedTreatment = toExpectedTreatment('a', undefined, undefined); |
@@ -774,3 +825,39 @@ describe('ExP Service delegate recreation', () => { |
774 | 825 | service.dispose(); |
775 | 826 | }); |
776 | 827 | }); |
| 828 | + |
| 829 | +/** |
| 830 | + * Records every request routed through the fetcher service so a test can assert that both TAS |
| 831 | + * endpoints go through it (proxy-aware transport) with the expected method and call site. |
| 832 | + */ |
| 833 | +class RecordingFetcherService extends mock<IFetcherService>() { |
| 834 | + public readonly calls: { url: string; method: string; callSite: string; body?: string }[] = []; |
| 835 | + |
| 836 | + override readonly onDidFetch = Event.None; |
| 837 | + override readonly onDidCompleteFetch = Event.None; |
| 838 | + |
| 839 | + override getUserAgentLibrary(): string { |
| 840 | + return 'test-fetcher'; |
| 841 | + } |
| 842 | + |
| 843 | + override fetch(url: string, options: FetchOptions): Promise<Response> { |
| 844 | + this.calls.push({ url, method: options.method ?? 'GET', callSite: options.callSite, body: options.body }); |
| 845 | + return Promise.resolve(Response.fromText(200, 'OK', new HeadersImpl({}), '{}', 'test-stub')); |
| 846 | + } |
| 847 | +} |
| 848 | + |
| 849 | +describe('TAS proxy transport adapter', () => { |
| 850 | + |
| 851 | + it('routes both the legacy GET and the assignments POST through the fetcher service with the expected call sites', async () => { |
| 852 | + const fetcher = new RecordingFetcherService(); |
| 853 | + const tasFetch = createTasFetch(fetcher); |
| 854 | + |
| 855 | + await tasFetch('https://default.exp-tas.com/vscode/ab', { method: 'GET', headers: { 'X-Legacy': '1' } }); |
| 856 | + await tasFetch('https://exp.example.test/vscode/api/v1/assignments', { method: 'POST', headers: { 'X-New': '1' }, body: '{"parameters":{}}' }); |
| 857 | + |
| 858 | + expect(fetcher.calls).toEqual([ |
| 859 | + { url: 'https://default.exp-tas.com/vscode/ab', method: 'GET', callSite: 'exp.legacy', body: undefined }, |
| 860 | + { url: 'https://exp.example.test/vscode/api/v1/assignments', method: 'POST', callSite: 'exp.assignments', body: '{"parameters":{}}' }, |
| 861 | + ]); |
| 862 | + }); |
| 863 | +}); |
0 commit comments