mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(app): gate a collapsing guild header behind an experiment (#2779)
This commit is contained in:
1 parent
550e6b05a1
commit
d0c6146429
29 files changed
+1449
-115
No files matched your search
@@ -35,6 +35,10 @@ import {
|
||||
GuildActivityLogPresentationConfigResponse,
|
||||
GuildActivityLogPresentationConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
|
||||
import {
|
||||
GuildHeaderCollapseConfigResponse,
|
||||
GuildHeaderCollapseConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
import {
|
||||
MessageHoverTrackingConfigResponse,
|
||||
MessageHoverTrackingConfigUpdateRequest,
|
||||
@@ -655,6 +659,7 @@ export const InstanceConfigResponse = z.object({
|
||||
message_keyboard_focus: MessageKeyboardFocusConfigResponse,
|
||||
blocked_message_groups: BlockedMessageGroupsConfigResponse,
|
||||
expression_info_card: ExpressionInfoCardConfigResponse,
|
||||
guild_header_collapse: GuildHeaderCollapseConfigResponse,
|
||||
registration: InstanceRegistrationResponse,
|
||||
self_hosted: z.boolean(),
|
||||
app_public: AppPublicConfigResponse,
|
||||
@@ -696,6 +701,7 @@ export const InstanceConfigUpdateRequest = z.object({
|
||||
message_keyboard_focus: MessageKeyboardFocusConfigUpdateRequest.nullish(),
|
||||
blocked_message_groups: BlockedMessageGroupsConfigUpdateRequest.nullish(),
|
||||
expression_info_card: ExpressionInfoCardConfigUpdateRequest.nullish(),
|
||||
guild_header_collapse: GuildHeaderCollapseConfigUpdateRequest.nullish(),
|
||||
registration: z
|
||||
.object({
|
||||
mode: InstanceRegistrationModeSchema.optional(),
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
GuildActivityLogPresentationAssignmentResponse,
|
||||
INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
|
||||
import {
|
||||
GuildHeaderCollapseAssignmentResponse,
|
||||
INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
import {
|
||||
INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT,
|
||||
MessageHoverTrackingAssignmentResponse,
|
||||
@@ -67,6 +71,7 @@ const ExperimentAssignmentsSchema = z.object({
|
||||
blocked_message_groups: BlockedMessageGroupsAssignmentResponse.optional(),
|
||||
guild_activity_log_presentation: GuildActivityLogPresentationAssignmentResponse.optional(),
|
||||
expression_info_card: ExpressionInfoCardAssignmentResponse.optional(),
|
||||
guild_header_collapse: GuildHeaderCollapseAssignmentResponse.optional(),
|
||||
});
|
||||
|
||||
export const ExperimentAssignmentsResponse = z.object({
|
||||
@@ -118,3 +123,9 @@ export function readExpressionInfoCardAssignment(
|
||||
): ExpressionInfoCardAssignmentResponse {
|
||||
return response.assignments.expression_info_card ?? INERT_EXPRESSION_INFO_CARD_ASSIGNMENT;
|
||||
}
|
||||
|
||||
export function readGuildHeaderCollapseAssignment(
|
||||
response: ExperimentAssignmentsResponse,
|
||||
): GuildHeaderCollapseAssignmentResponse {
|
||||
return response.assignments.guild_header_collapse ?? INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createClientBehaviorExperiment} from '@fluxer/schema/src/domains/experiment/ClientBehaviorExperiment';
|
||||
import type {z} from 'zod';
|
||||
|
||||
const guildHeaderCollapse = createClientBehaviorExperiment({defaultRolloutSalt: 'guild-header-collapse-v1'});
|
||||
|
||||
export const GuildHeaderCollapseConfigSchema = guildHeaderCollapse.ConfigSchema;
|
||||
|
||||
export type GuildHeaderCollapseConfig = z.infer<typeof GuildHeaderCollapseConfigSchema>;
|
||||
|
||||
export const DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG: GuildHeaderCollapseConfig = guildHeaderCollapse.DEFAULT_CONFIG;
|
||||
|
||||
export const GuildHeaderCollapseConfigUpdateRequest = guildHeaderCollapse.ConfigUpdateRequest;
|
||||
|
||||
export type GuildHeaderCollapseConfigUpdateRequest = z.infer<typeof GuildHeaderCollapseConfigUpdateRequest>;
|
||||
|
||||
export const GuildHeaderCollapseConfigResponse = guildHeaderCollapse.ConfigSchema;
|
||||
|
||||
export type GuildHeaderCollapseConfigResponse = GuildHeaderCollapseConfig;
|
||||
|
||||
export const GuildHeaderCollapseAssignmentResponse = guildHeaderCollapse.AssignmentResponse;
|
||||
|
||||
export type GuildHeaderCollapseAssignmentResponse = z.infer<typeof GuildHeaderCollapseAssignmentResponse>;
|
||||
|
||||
export const INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT: GuildHeaderCollapseAssignmentResponse =
|
||||
guildHeaderCollapse.INERT_ASSIGNMENT;
|
||||
|
||||
export function resolveGuildHeaderCollapseAssignment(
|
||||
config: GuildHeaderCollapseConfig,
|
||||
userId: string,
|
||||
): GuildHeaderCollapseAssignmentResponse {
|
||||
return guildHeaderCollapse.resolveAssignment(config, userId);
|
||||
}
|
||||
|
||||
export function isGuildHeaderCollapseTargeted(assignment: GuildHeaderCollapseAssignmentResponse): boolean {
|
||||
return assignment.enabled && assignment.user_targeted;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createClientBehaviorExperiment} from '@fluxer/schema/src/domains/experiment/ClientBehaviorExperiment';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const TARGETED_USER_CAP = 1000;
|
||||
|
||||
function userIds(count: number): Array<string> {
|
||||
return Array.from({length: count}, (_, index) => String(index + 1));
|
||||
}
|
||||
|
||||
const rollout = createClientBehaviorExperiment({defaultRolloutSalt: 'client-behaviour-cap-test'});
|
||||
|
||||
describe('createClientBehaviorExperiment targeted user lists', () => {
|
||||
it('accepts an included list at the thousand entry cap', () => {
|
||||
const config = rollout.ConfigSchema.parse({included_user_ids: userIds(TARGETED_USER_CAP)});
|
||||
|
||||
expect(config.included_user_ids).toHaveLength(TARGETED_USER_CAP);
|
||||
});
|
||||
|
||||
it('rejects an included list above the thousand entry cap', () => {
|
||||
expect(() => rollout.ConfigSchema.parse({included_user_ids: userIds(TARGETED_USER_CAP + 1)})).toThrow();
|
||||
});
|
||||
|
||||
it('accepts an excluded list at the thousand entry cap', () => {
|
||||
const config = rollout.ConfigSchema.parse({excluded_user_ids: userIds(TARGETED_USER_CAP)});
|
||||
|
||||
expect(config.excluded_user_ids).toHaveLength(TARGETED_USER_CAP);
|
||||
});
|
||||
|
||||
it('rejects an excluded list above the thousand entry cap', () => {
|
||||
expect(() => rollout.ConfigSchema.parse({excluded_user_ids: userIds(TARGETED_USER_CAP + 1)})).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {
|
||||
DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG,
|
||||
type GuildHeaderCollapseConfig,
|
||||
GuildHeaderCollapseConfigSchema,
|
||||
INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT,
|
||||
isGuildHeaderCollapseTargeted,
|
||||
resolveGuildHeaderCollapseAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const USER_ID = '1485064866382176262';
|
||||
const OTHER_USER_ID = '1485064866382176263';
|
||||
|
||||
function config(overrides: Partial<GuildHeaderCollapseConfig> = {}): GuildHeaderCollapseConfig {
|
||||
return {...DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG, ...overrides};
|
||||
}
|
||||
|
||||
describe('GuildHeaderCollapseConfigSchema', () => {
|
||||
it('defaults to a disabled rollout that targets nobody', () => {
|
||||
expect(DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG).toEqual({
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: 'guild-header-collapse-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a rollout share above the bucket resolution', () => {
|
||||
expect(() => GuildHeaderCollapseConfigSchema.parse({rollout_basis_points: 10001})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a target that is not a snowflake', () => {
|
||||
expect(() => GuildHeaderCollapseConfigSchema.parse({included_user_ids: ['not-a-snowflake']})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGuildHeaderCollapseAssignment', () => {
|
||||
it('reports the inert assignment with the stored revision while the rollout is off', () => {
|
||||
const assignment = resolveGuildHeaderCollapseAssignment(
|
||||
config({enabled: false, config_version: 4, rollout_basis_points: 10000}),
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
expect(assignment).toEqual({...INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT, config_version: 4});
|
||||
expect(isGuildHeaderCollapseTargeted(assignment)).toBe(false);
|
||||
});
|
||||
|
||||
it('targets every account at the full rollout share', () => {
|
||||
const assignment = resolveGuildHeaderCollapseAssignment(
|
||||
config({enabled: true, config_version: 2, rollout_basis_points: 10000}),
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
expect(assignment).toEqual({enabled: true, config_version: 2, user_targeted: true, source: 'canary'});
|
||||
expect(isGuildHeaderCollapseTargeted(assignment)).toBe(true);
|
||||
});
|
||||
|
||||
it('targets no account at a zero rollout share', () => {
|
||||
const assignment = resolveGuildHeaderCollapseAssignment(config({enabled: true}), USER_ID);
|
||||
|
||||
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null});
|
||||
expect(isGuildHeaderCollapseTargeted(assignment)).toBe(false);
|
||||
});
|
||||
|
||||
it('targets an allowlisted account outside the sampled share', () => {
|
||||
const assignment = resolveGuildHeaderCollapseAssignment(
|
||||
config({enabled: true, included_user_ids: [USER_ID]}),
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: true, source: 'user_rule'});
|
||||
});
|
||||
|
||||
it('excludes an account even when it is allowlisted and inside the share', () => {
|
||||
const assignment = resolveGuildHeaderCollapseAssignment(
|
||||
config({
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
included_user_ids: [USER_ID],
|
||||
excluded_user_ids: [USER_ID],
|
||||
}),
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null});
|
||||
});
|
||||
|
||||
it('resolves the same account the same way on every call', () => {
|
||||
const rollout = config({enabled: true, rollout_basis_points: 5000});
|
||||
const first = resolveGuildHeaderCollapseAssignment(rollout, USER_ID);
|
||||
const second = resolveGuildHeaderCollapseAssignment(rollout, USER_ID);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
it('draws a caller inside the share and not one exactly at the boundary', () => {
|
||||
const bucket = experimentBucket(USER_ID, DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG.rollout_salt);
|
||||
const inside = resolveGuildHeaderCollapseAssignment(
|
||||
config({enabled: true, rollout_basis_points: bucket + 1}),
|
||||
USER_ID,
|
||||
);
|
||||
const atBoundary = resolveGuildHeaderCollapseAssignment(
|
||||
config({enabled: true, rollout_basis_points: bucket}),
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
expect([inside.user_targeted, inside.source]).toEqual([true, 'canary']);
|
||||
expect([atBoundary.user_targeted, atBoundary.source]).toEqual([false, null]);
|
||||
});
|
||||
|
||||
it('excludes only the named account', () => {
|
||||
const rollout = config({enabled: true, rollout_basis_points: 10000, excluded_user_ids: [USER_ID]});
|
||||
|
||||
expect(resolveGuildHeaderCollapseAssignment(rollout, USER_ID).user_targeted).toBe(false);
|
||||
expect(resolveGuildHeaderCollapseAssignment(rollout, OTHER_USER_ID).user_targeted).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user