@quenty/chatproviderservice 9.67.3 → 9.67.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,12 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [9.67.4](https://github.com/Quenty/NevermoreEngine/compare/@quenty/chatproviderservice@9.67.3...@quenty/chatproviderservice@9.67.4) (2026-07-29)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **chatproviderservice:** let a PlayerMock be chat tagged ([#776](https://github.com/Quenty/NevermoreEngine/issues/776)) ([7d1843b](https://github.com/Quenty/NevermoreEngine/commit/7d1843b24225a3d3e2f70c2743688b28a0e2acb4))
11
+
6
12
  ## [9.67.3](https://github.com/Quenty/NevermoreEngine/compare/@quenty/chatproviderservice@9.67.2...@quenty/chatproviderservice@9.67.3) (2026-07-29)
7
13
 
8
14
  **Note:** Version bump only for package @quenty/chatproviderservice
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/chatproviderservice",
3
- "version": "9.67.3",
3
+ "version": "9.67.4",
4
4
  "description": "Provide wrapper around chat system to allow tags to be set",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -43,6 +43,7 @@
43
43
  "@quenty/nevermore-test-runner": "1.5.0",
44
44
  "@quenty/permissionprovider": "14.49.0",
45
45
  "@quenty/playerbinder": "14.46.0",
46
+ "@quenty/playermock": "1.6.0",
46
47
  "@quenty/playerutils": "8.41.0",
47
48
  "@quenty/preferredparentutils": "4.4.2",
48
49
  "@quenty/promise": "10.23.0",
@@ -60,5 +61,5 @@
60
61
  "publishConfig": {
61
62
  "access": "public"
62
63
  },
63
- "gitHead": "0215f657269834e0ec1272ac1bf97bbdf58c78cd"
64
+ "gitHead": "98488545769a2a9586da14845730dfb36d718744"
64
65
  }
@@ -7,6 +7,7 @@ local require = require(script.Parent.loader).load(script)
7
7
 
8
8
  local HttpService = game:GetService("HttpService")
9
9
  local Players = game:GetService("Players")
10
+ local RunService = game:GetService("RunService")
10
11
  local TextChatService = game:GetService("TextChatService")
11
12
 
12
13
  local Binder = require("Binder")
@@ -49,6 +50,12 @@ function ChatProviderServiceClient.Init(self: ChatProviderServiceClient, service
49
50
  end
50
51
 
51
52
  function ChatProviderServiceClient.Start(self: ChatProviderServiceClient): ()
53
+ -- The engine rejects this assignment outside a client, and a headless test that boots a client bag
54
+ -- on the server would take the whole bag down with it. Nothing to render there anyway.
55
+ if not RunService:IsClient() then
56
+ return
57
+ end
58
+
52
59
  TextChatService.OnIncomingMessage = function(textChatMessage): TextChatMessageProperties?
53
60
  self.MessageIncoming:Fire(textChatMessage)
54
61
 
@@ -17,6 +17,7 @@ local LocalizedTextUtils = require("LocalizedTextUtils")
17
17
  local Maid = require("Maid")
18
18
  local Observable = require("Observable")
19
19
  local PermissionLevel = require("PermissionLevel")
20
+ local PlayerMock = require("PlayerMock")
20
21
  local PreferredParentUtils = require("PreferredParentUtils")
21
22
  local Promise = require("Promise")
22
23
  local Rx = require("Rx")
@@ -173,7 +174,7 @@ function ChatProviderService.PromiseAddChatTag(
173
174
  player: Player,
174
175
  chatTagData: ChatTagDataUtils.ChatTagData
175
176
  ): Promise.Promise<Instance>
176
- assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player")
177
+ assert((typeof(player) == "Instance" and player:IsA("Player")) or PlayerMock.isMock(player), "Bad player")
177
178
  assert(ChatTagDataUtils.isChatTagData(chatTagData), "Bad chatTagData")
178
179
 
179
180
  local hasChatTagBinder = self._serviceBag:GetService(HasChatTags)
@@ -189,7 +190,7 @@ end
189
190
  @param player Player
190
191
  ]=]
191
192
  function ChatProviderService.ClearChatTags(self: ChatProviderService, player: Player)
192
- assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player")
193
+ assert((typeof(player) == "Instance" and player:IsA("Player")) or PlayerMock.isMock(player), "Bad player")
193
194
 
194
195
  local hasChatTagBinder = self._serviceBag:GetService(HasChatTags)
195
196
  local hasChatTags = hasChatTagBinder:Get(player)
@@ -300,4 +301,15 @@ function ChatProviderService._promiseSpeaker(self: ChatProviderService, speakerN
300
301
  end)
301
302
  end
302
303
 
304
+ --[=[
305
+ Cleans up the service. Should be done via [ServiceBag].
306
+
307
+ Load-bearing rather than tidy: the developer and admin tags hold a subscription to every player in
308
+ the place, which outlived this service without it. A destroyed bag then went on resolving
309
+ permissions and tagging players through services it no longer owns.
310
+ ]=]
311
+ function ChatProviderService.Destroy(self: ChatProviderService)
312
+ self._maid:DoCleaning()
313
+ end
314
+
303
315
  return ChatProviderService
@@ -0,0 +1,196 @@
1
+ --!strict
2
+ --[[
3
+ Coverage for the server chat-tag API against a PlayerMock. No real player joins a headless Open
4
+ Cloud place, so a mock has to be able to reach a chat tag the same way a joined player does --
5
+ through the PlayerBinder-backed HasChatTags, with the test creating nothing by hand but the mock.
6
+
7
+ The built-in (dev) and (mod) tags are switched off per test: they resolve through PermissionService,
8
+ which is a different question than whether a mock can be tagged at all.
9
+
10
+ @class ChatProviderService.spec.lua
11
+ ]]
12
+
13
+ local require = require(script.Parent.loader).load(script)
14
+
15
+ local ChatProviderService = require("ChatProviderService")
16
+ local ChatTagConstants = require("ChatTagConstants")
17
+ local ChatTagDataUtils = require("ChatTagDataUtils")
18
+ local HasChatTagsConstants = require("HasChatTagsConstants")
19
+ local Jest = require("Jest")
20
+ local PermissionProviderUtils = require("PermissionProviderUtils")
21
+ local PermissionService = require("PermissionService")
22
+ local PlayerMockService = require("PlayerMockService")
23
+ local ServiceBag = require("ServiceBag")
24
+
25
+ local afterEach = Jest.Globals.afterEach
26
+ local describe = Jest.Globals.describe
27
+ local expect = Jest.Globals.expect
28
+ local it = Jest.Globals.it
29
+
30
+ local DEMO_TAG = ChatTagDataUtils.createChatTagData({
31
+ TagText = "[DEMO]",
32
+ TagPriority = 5,
33
+ TagColor = Color3.fromRGB(245, 163, 27),
34
+ })
35
+
36
+ local activeController: any = nil
37
+
38
+ afterEach(function()
39
+ if activeController then
40
+ local controller = activeController
41
+ activeController = nil
42
+ controller.destroy()
43
+ end
44
+ end)
45
+
46
+ local remoteNameCounter = 0
47
+
48
+ -- Polls rather than observes: the tag arrives through a binder, a permission promise and an Rx chain,
49
+ -- and the point of these tests is the end state rather than which hop delivered it.
50
+ local function waitForTagCount(player: Player, expected: number, timeout: number): number
51
+ local deadline = os.clock() + timeout
52
+
53
+ repeat
54
+ local container = player:FindFirstChild(HasChatTagsConstants.TAG_CONTAINER_NAME)
55
+ local count = if container then #container:GetChildren() else 0
56
+ if count == expected then
57
+ return count
58
+ end
59
+ task.wait(0.1)
60
+ until os.clock() > deadline
61
+
62
+ local container = player:FindFirstChild(HasChatTagsConstants.TAG_CONTAINER_NAME)
63
+ return if container then #container:GetChildren() else 0
64
+ end
65
+
66
+ -- creatorUserId, when given, makes that user the creator so the built-in developer tag actually
67
+ -- applies to them. Left nil, both built-in tags are switched off: they resolve through
68
+ -- PermissionService, which is a different question than whether a mock can be tagged at all.
69
+ local function setup(creatorUserId: number?)
70
+ local serviceBag = ServiceBag.new()
71
+ local container = Instance.new("Folder")
72
+ container.Name = "ChatProviderServiceSpecContainer"
73
+ container.Parent = workspace
74
+
75
+ local chatProviderService: any = serviceBag:GetService(ChatProviderService)
76
+ local permissionService: any = serviceBag:GetService(PermissionService)
77
+ local playerMockService: any = serviceBag:GetService(PlayerMockService)
78
+
79
+ serviceBag:Init()
80
+
81
+ if creatorUserId then
82
+ remoteNameCounter += 1
83
+ permissionService:SetProviderFromConfig(PermissionProviderUtils.createSingleUserConfig({
84
+ userId = creatorUserId,
85
+ remoteFunctionName = string.format("ChatProviderServiceSpecRemote%d", remoteNameCounter),
86
+ }))
87
+ end
88
+
89
+ serviceBag:Start()
90
+
91
+ if not creatorUserId then
92
+ chatProviderService:SetDeveloperTag(nil)
93
+ chatProviderService:SetAdminTag(nil)
94
+ end
95
+
96
+ local mocks: { Player } = {}
97
+ local bagDestroyed = false
98
+
99
+ local function destroyBag()
100
+ if not bagDestroyed then
101
+ bagDestroyed = true
102
+ serviceBag:Destroy()
103
+ end
104
+ end
105
+
106
+ local controller
107
+ controller = {
108
+ chatProviderService = chatProviderService,
109
+ newMock = function(userId: number?): Player
110
+ local mock = playerMockService:CreatePlayer(if userId then { UserId = userId } else nil)
111
+ mock.Parent = container
112
+ table.insert(mocks, mock)
113
+ return mock
114
+ end,
115
+ destroyBag = destroyBag,
116
+ destroy = function()
117
+ destroyBag()
118
+
119
+ for _, mock in mocks do
120
+ mock:Destroy()
121
+ end
122
+ table.clear(mocks)
123
+
124
+ container:Destroy()
125
+ end,
126
+ }
127
+
128
+ activeController = controller
129
+ return controller
130
+ end
131
+
132
+ describe("ChatProviderService.PromiseAddChatTag", function()
133
+ it("tags a player mock", function()
134
+ local controller = setup()
135
+ local player = controller.newMock()
136
+
137
+ local tag = controller.chatProviderService:PromiseAddChatTag(player, DEMO_TAG):Wait()
138
+
139
+ expect(tag:GetAttribute(ChatTagConstants.TAG_TEXT_ATTRIBUTE)).toBe(DEMO_TAG.TagText)
140
+ expect(tag:IsDescendantOf(player)).toBe(true)
141
+ end)
142
+
143
+ it("drops the tag when the instance is destroyed", function()
144
+ local controller = setup()
145
+ local player = controller.newMock()
146
+
147
+ local tag = controller.chatProviderService:PromiseAddChatTag(player, DEMO_TAG):Wait()
148
+ tag:Destroy()
149
+
150
+ expect(tag:IsDescendantOf(player)).toBe(false)
151
+ end)
152
+ end)
153
+
154
+ describe("ChatProviderService.SetDeveloperTag", function()
155
+ local CREATOR_USER_ID = 4242
156
+
157
+ it("tags the creator", function()
158
+ local controller = setup(CREATOR_USER_ID)
159
+ local player = controller.newMock(CREATOR_USER_ID)
160
+
161
+ expect(waitForTagCount(player, 1, 10)).toBe(1)
162
+ end)
163
+
164
+ it("leaves everybody else alone", function()
165
+ local controller = setup(CREATOR_USER_ID)
166
+ local player = controller.newMock(CREATOR_USER_ID + 1)
167
+
168
+ expect(waitForTagCount(player, 0, 3)).toBe(0)
169
+ end)
170
+
171
+ --[[
172
+ The tag holds a subscription to every player in the place, so without a Destroy it outlived its
173
+ own bag: a destroyed service went on resolving permissions and reaching for binders through
174
+ services it no longer owned.
175
+ ]]
176
+ it("stops tagging once its bag is destroyed", function()
177
+ local controller = setup(CREATOR_USER_ID)
178
+ controller.destroyBag()
179
+
180
+ local player = controller.newMock(CREATOR_USER_ID)
181
+
182
+ expect(waitForTagCount(player, 0, 3)).toBe(0)
183
+ end)
184
+ end)
185
+
186
+ describe("ChatProviderService.ClearChatTags", function()
187
+ it("clears a player mock's tags", function()
188
+ local controller = setup()
189
+ local player = controller.newMock()
190
+
191
+ local tag = controller.chatProviderService:PromiseAddChatTag(player, DEMO_TAG):Wait()
192
+ controller.chatProviderService:ClearChatTags(player)
193
+
194
+ expect(tag.Parent).toBe(nil)
195
+ end)
196
+ end)