Skip to content

Commit dd23f88

Browse files
authored
chore(cms): Setting up OT editor for integration testing (#301)
* setting up editor for integration testing * oops * review changes * review changes
1 parent e5e128e commit dd23f88

File tree

9 files changed

+179
-16
lines changed

9 files changed

+179
-16
lines changed

backend/editor/OT/client_view.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,12 @@ func (c *clientView) run(serverPipe pipe, terminatePipe alertLeaving) {
5656
// push the update to the documentServer
5757
if request, err := operations.ParseOperation(string(msg)); err == nil {
5858
serverPipe(request)
59-
continue
6059
}
60+
} else {
61+
// todo: push a terminate signal to the client, also tell the server we're leaving
62+
terminatePipe()
63+
c.socket.Close()
6164
}
62-
63-
// todo: push a terminate signal to the client, also tell the server we're leaving
64-
terminatePipe()
65-
c.socket.Close()
6665
}
6766
}
6867
}

backend/editor/OT/document_server.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package editor
22

33
import (
4+
"log"
45
"sync"
56

67
"cms.csesoc.unsw.edu.au/editor/OT/operations"
@@ -28,6 +29,7 @@ type clientState struct {
2829
canSendOps bool
2930
}
3031

32+
// todo: newDocumentServer should take an initial state
3133
func newDocumentServer() *documentServer {
3234
// ideally state shouldn't be a string due to its immutability
3335
// any update requires the allocation + copy of a new string in memory
@@ -114,21 +116,22 @@ func (s *documentServer) buildClientPipe(clientID int, workerWorkHandle chan fun
114116
// apply op to clientView states
115117
s.stateLock.Lock()
116118

117-
// TODO: apply operation
118-
// - Blockers:
119-
// - Gary's TLB
120-
// - Updated Traversal
121-
122119
// apply the operation locally and log the new operation
123120
transformedOperation := s.transformOperation(op)
124121
s.operationHistory = append(s.operationHistory, transformedOperation)
125122

126-
s.stateLock.Unlock()
127-
128-
if transformedOperation.IsNoOp {
129-
return
123+
if !transformedOperation.IsNoOp {
124+
newState, err := op.ApplyTo(s.state)
125+
if err != nil {
126+
log.Fatal(err)
127+
clientState.sendTerminateSignal <- empty{}
128+
} else {
129+
s.state = newState
130+
}
130131
}
131132

133+
s.stateLock.Unlock()
134+
132135
// propagate updates to all connected clients except this one
133136
// if we send it to this clientView then we may deadlock the server and clientView
134137
s.clientsLock.Lock()

backend/editor/OT/operations/traversal.go renamed to backend/editor/OT/operations/application.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,13 @@ func Traverse(document cmsjson.AstNode, subpaths []int) (cmsjson.AstNode, cmsjso
2828

2929
return prev, curr, nil
3030
}
31+
32+
func (op Operation) ApplyTo(document cmsjson.AstNode) (cmsjson.AstNode, error) {
33+
parent, _, err := Traverse(document, op.Path)
34+
if err != nil {
35+
return nil, fmt.Errorf("failed to apply operation %v at target site: %w", op, err)
36+
}
37+
38+
applicationIndex := op.Path[len(op.Path)-1]
39+
return op.Operation.Apply(parent, applicationIndex, op.OperationType)
40+
}

backend/editor/OT/operations/json_config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
// cmsjson works with arbitrary schemas so this model can be changed on a whim
1414
// note that cmsjson does not check that the provided types implement the interface
1515
// so please check that everything works prior to running the CMS
16-
var cmsJsonConf = cmsjson.Configuration{
16+
var CmsJsonConf = cmsjson.Configuration{
1717
// Registration for cmsmodel, when the LP is finally merged with CSESoc Projects
1818
// this will also contain the registration for their data models
1919
RegisteredTypes: map[reflect.Type]map[string]reflect.Type{

backend/editor/OT/operations/operation_model.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ var NoOperation = Operation{IsNoOp: true, Operation: Noop{}}
3838
// a Request object
3939
func ParseOperation(request string) (Operation, error) {
4040
var operation Operation
41-
if err := cmsjson.Unmarshall[Operation](cmsJsonConf, &operation, []byte(request)); err != nil {
41+
if err := cmsjson.Unmarshall[Operation](CmsJsonConf, &operation, []byte(request)); err != nil {
4242
return Operation{}, err
4343
} else {
4444
return operation, nil
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package editor
2+
3+
import (
4+
"cms.csesoc.unsw.edu.au/editor/OT/datamodel"
5+
"cms.csesoc.unsw.edu.au/editor/OT/operations"
6+
"cms.csesoc.unsw.edu.au/environment"
7+
"cms.csesoc.unsw.edu.au/pkg/cmsjson"
8+
"github.com/google/uuid"
9+
"github.com/gorilla/websocket"
10+
)
11+
12+
// testing_framework is a simple framework that can be used for performing integration tests on the concurrent editor
13+
// it allows for the observation of client behavior and tracking document server behavior
14+
type TestingClient struct {
15+
underlyingClient *clientView
16+
operationPipe pipe
17+
terminationPipe func()
18+
}
19+
20+
func (tC TestingClient) HasTerminated() bool { return len(tC.underlyingClient.sendTerminateSignal) > 0 }
21+
func (tC TestingClient) WasAcknowledged() bool {
22+
if len(tC.underlyingClient.sendAcknowledgement) > 0 {
23+
// potential "unexpected" blocking condition here but for the sake of testing its fine
24+
// consider two goroutines one of them is this function
25+
// - this function reads the length and enters this if statement
26+
// - prior to the next statement another goroutine reads from the sendAck channel emptying it
27+
// - goroutine running this function is blocked until next ack (very tragic btw)
28+
// for the purpose of testing tho this should be fine :)
29+
<-tC.underlyingClient.sendAcknowledgement
30+
return true
31+
}
32+
return false
33+
}
34+
35+
func (tC TestingClient) GetReceivedOp() operations.Operation {
36+
if len(tC.underlyingClient.sendOp) == 0 {
37+
panic("testing client failure: expected a non-zero amount of received operations")
38+
}
39+
40+
return <-tC.underlyingClient.sendOp
41+
}
42+
43+
// GetServerState returns the current view that the server sees (as a string)
44+
func GetServerState(serverId uuid.UUID) string {
45+
if !environment.IsTestingEnvironment() {
46+
panic("method can only be called within the context of a test!")
47+
}
48+
49+
connectedServer := GetDocumentServerFactoryInstance().FetchDocumentServer(serverId)
50+
connectedServer.stateLock.Lock()
51+
defer connectedServer.stateLock.Unlock()
52+
53+
return operations.CmsJsonConf.MarshallAST(connectedServer.state)
54+
}
55+
56+
// CreateServer constructs a server with an initial state, it registers the server under the document manager
57+
// and returns the server's ID
58+
func CreateTestingServer(initState string) uuid.UUID {
59+
if !environment.IsTestingEnvironment() {
60+
panic("method can only be called within the context of a test!")
61+
}
62+
63+
var err error
64+
factory := GetDocumentServerFactoryInstance()
65+
serverId := uuid.New()
66+
67+
factory.lock.Lock()
68+
defer factory.lock.Unlock()
69+
factory.activeServers[serverId] = newDocumentServer()
70+
71+
factory.activeServers[serverId].state, err = cmsjson.UnmarshallAST[datamodel.Document](operations.CmsJsonConf, initState)
72+
if err != nil {
73+
panic(err)
74+
}
75+
76+
return serverId
77+
}
78+
79+
// buildClients starts up n clients and returns their client views as an array
80+
func BuildTestingClient(serverId uuid.UUID, numClients int) []TestingClient {
81+
if !environment.IsTestingEnvironment() {
82+
panic("method can only be called within the context of a test!")
83+
}
84+
85+
connectedServer := GetDocumentServerFactoryInstance().FetchDocumentServer(serverId)
86+
87+
clients := make([]TestingClient, numClients)
88+
for clientId := range clients {
89+
internalView := newClient(&websocket.Conn{})
90+
operationPipe, terminationPipe := connectedServer.connectClient(internalView)
91+
92+
clients[clientId] = TestingClient{
93+
underlyingClient: internalView,
94+
operationPipe: operationPipe,
95+
terminationPipe: terminationPipe,
96+
}
97+
}
98+
99+
return clients
100+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package tests
2+
3+
// This test suite performs full integration tests on the entire concurrent editor
4+
// the test suite is probably super fragile (as is the nature of a lot of integration tests :( ) but should give you assurance during any refactoring job you do

backend/pkg/cmsjson/ast_marshaller.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package cmsjson
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"strings"
7+
)
8+
9+
// Marshall can potentially be a rather expensive operation
10+
// why? Strings in go are immutable, if we recursively modify
11+
// a string each invocation ends up allocation a new string on the heap
12+
// resulting in GC overhead :P
13+
func (c Configuration) MarshallAST(source AstNode) string {
14+
asPrimitive, _ := source.JsonPrimitive()
15+
asObject, _ := source.JsonObject()
16+
asArray, _ := source.JsonArray()
17+
18+
switch {
19+
case asPrimitive != nil:
20+
return stringifyPrimitive(asPrimitive)
21+
case asObject != nil:
22+
result := strings.Builder{}
23+
for _, node := range asObject {
24+
result.WriteString(fmt.Sprintf("\"%s\": %s", node.GetKey(), c.MarshallAST(node)))
25+
}
26+
27+
return fmt.Sprintf("{%s}", result.String())
28+
default:
29+
result := strings.Builder{}
30+
for _, node := range asArray {
31+
result.WriteString(c.MarshallAST(node) + ",")
32+
}
33+
34+
return fmt.Sprintf("[%s]", result.String())
35+
}
36+
}
37+
38+
func stringifyPrimitive(primitive interface{}) string {
39+
switch parsedPrimitive := primitive.(type) {
40+
case string:
41+
return "\"" + parsedPrimitive + "\""
42+
case bool:
43+
return strconv.FormatBool(parsedPrimitive)
44+
default:
45+
return strconv.FormatInt(parsedPrimitive.(int64), 10)
46+
}
47+
}

0 commit comments

Comments
 (0)